agents-relay 1.0.15 → 1.0.17

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
@@ -10,6 +10,11 @@ For GitHub-backed work, create or adopt the PR through Agents Relay itself. This
10
10
  npx agents-relay job create --repo OWNER/REPO --head feat/example --base main \
11
11
  --id job-1 --title "Objective" --body "PR description" --mode fixed
12
12
 
13
+ # Create/reuse the managed job and submit its initial task atomically
14
+ npx agents-relay job create --repo OWNER/REPO --head feat/example --base main \
15
+ --id job-1 --start --task-id initial --adapter codex --provider openai \
16
+ --model MODEL --output task-pr --input "Implement the objective and publish a terminal event."
17
+
13
18
  npx agents-relay submit --repo OWNER/REPO --pr 12 --id job-1 \
14
19
  --task-id child --adapter codex --provider openai --model MODEL --output task-pr \
15
20
  --input "Implement the described child task and publish a terminal event."
@@ -35,6 +40,7 @@ Planner-created tasks use stable IDs and the existing parent/subtask tree, so
35
40
  retries and daemon restarts do not create duplicate work.
36
41
 
37
42
  job create first resolves an existing open PR with the same head/base, then creates it only when needed. Re-running it is idempotent: it reuses the PR and maintains exactly one trusted agents-relay:job:v1 marker.
43
+ Use `--start` to create/reuse the job and submit its initial task in one invocation. Start mode requires explicit `--adapter`, `--model`, `--output`, and non-empty `--input`; the task ID defaults to `initial`. Re-running with the same task definition reuses that task and never duplicates it. Omitting `--start` preserves create-only behavior, leaving an `OPEN` job with zero tasks until a caller submits work.
38
44
 
39
45
  To adopt an existing unmanaged PR, including a PR with no comments:
40
46
 
@@ -93,7 +99,7 @@ Managed tasks are descriptive work for agents, not shell commands. Use `codex` o
93
99
 
94
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.
95
101
 
96
- 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. A process exit or browser submission receipt does not complete a task. The executing agent must publish exactly one correlated `task.completed`, `task.failed`, or `task.blocked` 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`, 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.
97
103
 
98
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.
99
105
 
package/dist/adapters.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { access, readFile } from 'node:fs/promises';
2
+ import { access, readFile, writeFile, unlink } from 'node:fs/promises';
3
3
  import { homedir } from 'node:os';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
@@ -42,7 +42,7 @@ export class CodexAdapter {
42
42
  constructor(command = 'codex') {
43
43
  this.command = command;
44
44
  }
45
- launch(task, signal) {
45
+ launch(task, signal, context = {}) {
46
46
  if (!task.routing)
47
47
  throw new Error(`Task ${task.id} requires routing metadata before Codex launch`);
48
48
  const child = spawn(this.command, buildCodexArgs(task.routing, task.input), {
@@ -53,6 +53,10 @@ export class CodexAdapter {
53
53
  let stdoutBuffer = '';
54
54
  let stderrTail = '';
55
55
  let bufferedThreadId;
56
+ let launchedResolve;
57
+ let launchedReject;
58
+ const launched = new Promise((resolve, reject) => { launchedResolve = resolve; launchedReject = reject; });
59
+ launched.catch(() => { });
56
60
  let execution;
57
61
  const consumeLine = (line) => {
58
62
  const threadId = parseThreadStarted(line);
@@ -75,7 +79,8 @@ export class CodexAdapter {
75
79
  stderrTail = appendTail(stderrTail, chunk.toString());
76
80
  });
77
81
  const promise = new Promise((resolve, reject) => {
78
- child.on('error', reject);
82
+ child.on('error', error => { launchedReject(error); reject(error); });
83
+ child.once('spawn', launchedResolve);
79
84
  child.on('close', code => {
80
85
  if (stdoutBuffer)
81
86
  consumeLine(stdoutBuffer);
@@ -88,13 +93,15 @@ export class CodexAdapter {
88
93
  });
89
94
  signal.addEventListener('abort', () => child.kill('SIGTERM'), { once: true });
90
95
  });
91
- execution = { id: randomUUID(), promise, cancel: () => child.kill('SIGTERM') };
96
+ execution = { id: randomUUID(), promise, launched, cancel: () => child.kill('SIGTERM') };
92
97
  execution.threadId = bufferedThreadId;
93
98
  return execution;
94
99
  }
95
100
  }
96
101
  export class LaunchFailure extends Error {
97
102
  }
103
+ export class StartAckTimeout extends LaunchFailure {
104
+ }
98
105
  function browserWorkerCandidates(explicit) {
99
106
  const roots = (process.env.AGENTS_RELAY_SKILL_ROOTS ?? '').split(':').filter(Boolean);
100
107
  const packageSkills = join(dirname(fileURLToPath(import.meta.url)), '..', 'skills');
@@ -140,14 +147,27 @@ function parseDriverJson(output) {
140
147
  }
141
148
  function directBrowserWorkerRunner(root) {
142
149
  return (_definition, request, _route, signal) => {
143
- const args = [join(root, 'scripts', 'temporary_bh.py'), '--prompt', request.prompt];
150
+ 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];
144
152
  for (const file of request.files ?? [])
145
153
  args.push('--file', file);
146
154
  const child = spawn('python3', args, { shell: false, stdio: ['ignore', 'pipe', 'pipe'] });
147
155
  let stdout = '';
148
156
  let stderr = '';
157
+ let submitted = false;
158
+ let submittedResolve;
159
+ let submittedReject;
160
+ const submittedPromise = new Promise((resolve, reject) => { submittedResolve = resolve; submittedReject = reject; });
149
161
  child.stdout?.on('data', (chunk) => {
150
162
  stdout += chunk.toString();
163
+ if (!submitted) {
164
+ try {
165
+ const response = parseDriverJson(stdout);
166
+ submitted = true;
167
+ submittedResolve?.(JSON.stringify(response));
168
+ }
169
+ catch { /* receipt may be split across chunks */ }
170
+ }
151
171
  });
152
172
  child.stderr?.on('data', (chunk) => {
153
173
  stderr = appendTail(stderr, chunk.toString());
@@ -156,11 +176,18 @@ function directBrowserWorkerRunner(root) {
156
176
  child.on('error', reject);
157
177
  child.on('close', code => {
158
178
  if (code !== 0) {
159
- reject(new Error(stderr.trim() || stdout.trim() || `browser-worker driver exited ${code}`));
179
+ const error = new Error(stderr.trim() || stdout.trim() || `browser-worker driver exited ${code}`);
180
+ submittedReject?.(error);
181
+ reject(error);
160
182
  return;
161
183
  }
162
184
  try {
163
- resolve(JSON.stringify(parseDriverJson(stdout)));
185
+ const result = JSON.stringify(parseDriverJson(stdout));
186
+ if (!submitted) {
187
+ submitted = true;
188
+ submittedResolve?.(result);
189
+ }
190
+ resolve(result);
164
191
  }
165
192
  catch (error) {
166
193
  reject(error);
@@ -171,7 +198,9 @@ function directBrowserWorkerRunner(root) {
171
198
  else
172
199
  signal.addEventListener('abort', () => child.kill('SIGTERM'), { once: true });
173
200
  });
174
- return { promise, cancel: () => child.kill('SIGTERM') };
201
+ const release = () => { void writeFile(releaseFile, 'release').catch(() => { }); };
202
+ promise.finally(() => { void unlink(releaseFile).catch(() => { }); }).catch(() => { });
203
+ return { promise, submitted: submittedPromise, release, cancel: () => { release(); child.kill('SIGTERM'); } };
175
204
  };
176
205
  }
177
206
  function parseBrowserWorkerResponse(output) {
@@ -203,7 +232,7 @@ export class ChatGptAdapter {
203
232
  this.agentPath = options.agentPath ?? process.env.AGENTS_RELAY_CHATGPT_BROWSER_WORKER_AGENT;
204
233
  this.runner = options.agentRunner;
205
234
  }
206
- launch(task, signal) {
235
+ launch(task, signal, context = {}) {
207
236
  if (!task.routing)
208
237
  throw new Error(`Task ${task.id} requires routing metadata before ChatGPT launch`);
209
238
  const route = task.routing;
@@ -218,14 +247,24 @@ export class ChatGptAdapter {
218
247
  promise: Promise.resolve({ summary: '' }),
219
248
  cancel: abort,
220
249
  };
250
+ let launchResolve;
251
+ let launchReject;
252
+ execution.launched = new Promise((resolve, reject) => { launchResolve = resolve; launchReject = reject; });
253
+ execution.launched.catch(() => { });
221
254
  execution.promise = (async () => {
222
255
  const runtime = await loadBrowserWorkerRuntime(this.agentPath);
223
256
  if (controller.signal.aborted)
224
257
  throw new Error('browser-worker execution aborted');
225
258
  const run = (this.runner ?? directBrowserWorkerRunner(runtime.root))(runtime.definition, { prompt: task.input }, route, controller.signal);
226
259
  try {
227
- const output = await run.promise;
260
+ const output = await (run.submitted ?? run.promise);
228
261
  const response = parseBrowserWorkerResponse(output);
262
+ launchResolve();
263
+ if (context.waitForWorkerStart)
264
+ await context.waitForWorkerStart;
265
+ run.release?.();
266
+ if (run.submitted)
267
+ await run.promise;
229
268
  return {
230
269
  summary: 'ChatGPT task submitted; terminal task state is event-owned',
231
270
  data: {
@@ -236,8 +275,12 @@ export class ChatGptAdapter {
236
275
  };
237
276
  }
238
277
  catch (error) {
278
+ launchReject(error);
279
+ run.release?.();
239
280
  if (controller.signal.aborted)
240
281
  throw error;
282
+ if (error instanceof StartAckTimeout)
283
+ throw error;
241
284
  throw new LaunchFailure(error instanceof Error ? error.message : String(error));
242
285
  }
243
286
  })();
package/dist/cli.js CHANGED
@@ -43,7 +43,7 @@ Usage:
43
43
  npx agents-relay job <create|adopt|repair> [options]
44
44
 
45
45
  Actions:
46
- create Find or create an open PR, then persist its job marker
46
+ create Find or create an open PR, then persist its job marker; use --start to submit the initial task
47
47
  adopt Attach a job marker to an existing PR
48
48
  repair Repair duplicate markers for the same job
49
49
 
@@ -54,6 +54,7 @@ Common options:
54
54
  --title TEXT Job title
55
55
  --objective TEXT Objective for autonomous mode
56
56
  --mode fixed|autonomous Execution mode (default: fixed)
57
+ --start Create/reuse the job and submit its initial task
57
58
 
58
59
  Example:
59
60
  npx agents-relay job create --repo OWNER/REPO --head feat/example --base main --id job-1`,
@@ -61,6 +62,8 @@ Example:
61
62
 
62
63
  Required: --repo OWNER/REPO, --id JOB_ID, and --head BRANCH.
63
64
  Common: --base BRANCH (default: main), --title TEXT, --body TEXT, --mode fixed|autonomous.
65
+ With --start, also require --adapter codex|chatgpt, --model MODEL, --output task-pr|file,
66
+ and --input TEXT. The initial task ID defaults to 'initial'; use --task-id to override it.
64
67
 
65
68
  Example:
66
69
  npx agents-relay job create --repo OWNER/REPO --head feat/example --base main --id job-1 --title "Objective"`,
@@ -255,6 +258,53 @@ export async function ensureManagedGitHubJob(client, repositoryName, trustedAuth
255
258
  await store.saveJob(job);
256
259
  return { job: await store.load(options.id), pr };
257
260
  }
261
+ function workerTaskFromArgs(job, args, now, requireInput = false) {
262
+ const model = arg(args, '--model');
263
+ const adapterValue = arg(args, '--adapter');
264
+ const adapter = (adapterValue || 'codex');
265
+ const outputKind = arg(args, '--output');
266
+ const outputPath = arg(args, '--output-path');
267
+ const input = arg(args, '--input');
268
+ if (!['codex', 'chatgpt'].includes(adapter))
269
+ throw new Error('submit adapter must be codex or chatgpt');
270
+ if (!model)
271
+ throw new Error('submit requires --model for codex/chatgpt tasks');
272
+ if (requireInput && !input)
273
+ throw new Error('job create --start requires --input');
274
+ if (outputKind !== 'task-pr' && outputKind !== 'file')
275
+ throw new Error('submit requires --output task-pr|file');
276
+ if (outputKind === 'file' && !outputPath)
277
+ throw new Error('--output file requires --output-path');
278
+ 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 };
279
+ return {
280
+ jobId: job.id,
281
+ id: arg(args, '--task-id', 'initial'),
282
+ priority: priority(args, job.priority ?? 'P2'),
283
+ projectName: arg(args, '--project') || undefined,
284
+ agentName: arg(args, '--agent') || undefined,
285
+ parentTaskId: arg(args, '--parent') || null,
286
+ dependencies: arg(args, '--deps').split(',').filter(Boolean),
287
+ capabilities: arg(args, '--capabilities').split(',').filter(Boolean),
288
+ adapter,
289
+ input: input || 'true',
290
+ output: outputKind === 'file' ? { kind: 'file', path: outputPath } : { kind: 'task_pr' },
291
+ routing,
292
+ continuation: continuation(args),
293
+ continuationDeliveredAt: null,
294
+ state: 'QUEUED',
295
+ attempt: 0,
296
+ maxAttempts: Number(arg(args, '--max-attempts', '3')),
297
+ leaseOwner: null,
298
+ leaseExpiresAt: null,
299
+ executionId: null,
300
+ threadId: null,
301
+ result: null,
302
+ error: null,
303
+ timeoutMs: Number(arg(args, '--timeout', '300000')),
304
+ createdAt: now,
305
+ updatedAt: now
306
+ };
307
+ }
258
308
  async function runTaskCommand(action, args) {
259
309
  if (action !== 'update')
260
310
  throw new Error(usage);
@@ -322,6 +372,14 @@ async function runJobCommand(action, args) {
322
372
  const id = arg(args, '--id');
323
373
  if (!id)
324
374
  throw new Error('--id is required for managed GitHub jobs');
375
+ const start = hasArg(args, '--start');
376
+ if (start && action !== 'create')
377
+ throw new Error('--start is only supported by job create');
378
+ if (start) {
379
+ if (!hasArg(args, '--adapter'))
380
+ throw new Error('job create --start requires --adapter codex|chatgpt');
381
+ workerTaskFromArgs({ id, title: '', executionMode: 'fixed', prNumber: 0, repository: repo, state: 'OPEN', continuation: null, createdAt: '', updatedAt: '', tasks: [] }, args, new Date().toISOString(), true);
382
+ }
325
383
  const auth = await githubAuthContext(args);
326
384
  const client = auth.client;
327
385
  const trustedAuthors = auth.trustedAuthors;
@@ -343,7 +401,17 @@ async function runJobCommand(action, args) {
343
401
  base: arg(args, '--base', 'main'),
344
402
  body: arg(args, '--body') || undefined
345
403
  });
346
- console.log(JSON.stringify({ repository: repo, pr: result.pr.number, job: result.job.id, executionMode: result.job.executionMode, state: result.job.state }, null, 2));
404
+ if (start) {
405
+ const task = workerTaskFromArgs(result.job, args, new Date().toISOString(), true);
406
+ const existing = result.job.tasks.find(item => item.id === task.id);
407
+ const store = new GitHubStore(client, repo, result.pr.number, trustedAuthors);
408
+ await store.appendTask(task);
409
+ if (!existing) {
410
+ await publishWake(eventBus(args), result.job, task.id, `Task ${task.id} submitted`);
411
+ result.job.tasks.push(task);
412
+ }
413
+ }
414
+ 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));
347
415
  }
348
416
  export function runtimePlanner(args) { const plannerCommand = arg([...args], '--planner-command'); const modelRuntime = arg([...args], '--codex', 'codex'); return plannerCommand ? new CommandObjectivePlanner(plannerCommand) : new AgentObjectivePlanner(modelRuntime); }
349
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 }); }
@@ -489,20 +557,8 @@ async function main() {
489
557
  }
490
558
  if (command === 'submit') {
491
559
  const now = new Date().toISOString();
492
- const model = arg(args, '--model');
493
- const adapter = arg(args, '--adapter', 'codex');
494
- const outputKind = arg(args, '--output');
495
- const outputPath = arg(args, '--output-path');
496
- if (!['codex', 'chatgpt'].includes(adapter))
497
- throw new Error('submit adapter must be codex or chatgpt');
498
- if (!model)
499
- throw new Error('submit requires --model for codex/chatgpt tasks');
500
- if (outputKind !== 'task-pr' && outputKind !== 'file')
501
- throw new Error('submit requires --output task-pr|file');
502
- if (outputKind === 'file' && !outputPath)
503
- throw new Error('--output file requires --output-path');
504
- const routing = model ? { 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 } : undefined;
505
- const task = { jobId: job.id, id: arg(args, '--task-id', randomUUID()), priority: priority(args, job.priority ?? 'P2'), projectName: arg(args, '--project') || undefined, agentName: arg(args, '--agent') || undefined, parentTaskId: arg(args, '--parent') || null, dependencies: arg(args, '--deps').split(',').filter(Boolean), capabilities: arg(args, '--capabilities').split(',').filter(Boolean), adapter, input: arg(args, '--input', 'true'), output: outputKind === 'file' ? { kind: 'file', path: outputPath } : { kind: 'task_pr' }, routing, continuation: continuation(args), continuationDeliveredAt: null, state: 'QUEUED', attempt: 0, maxAttempts: Number(arg(args, '--max-attempts', '3')), leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, error: null, timeoutMs: Number(arg(args, '--timeout', '300000')), createdAt: now, updatedAt: now };
560
+ const task = workerTaskFromArgs(job, args, now);
561
+ task.id = arg(args, '--task-id', randomUUID());
506
562
  await store.appendTask(task);
507
563
  job.tasks.push(task);
508
564
  if (job.state === 'COMPLETED') {
package/dist/dashboard.js CHANGED
@@ -1,7 +1,11 @@
1
1
  import { createServer } from 'node:http';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
2
5
  import { DeliveryDeduper, parseGitHubWebhook, verifyGitHubWebhook } from './github-webhook.js';
3
6
  const GITHUB_REPOSITORY_PATTERN = /^([A-Za-z0-9](?:[A-Za-z0-9-]{0,36}[A-Za-z0-9])?)\/([A-Za-z0-9._-]{1,100})$/;
4
7
  const SSE_HEARTBEAT_MS = 15000;
8
+ const EVENTS_HISTORY_FILE = process.env.EVENTS_BUS_DATA_DIR ? join(process.env.EVENTS_BUS_DATA_DIR, 'events.jsonl') : join(homedir(), 'Library', 'Application Support', 'EventsBus', 'events.jsonl');
5
9
  function isPositiveInteger(candidate) {
6
10
  return typeof candidate === 'number' && Number.isInteger(candidate) && candidate > 0;
7
11
  }
@@ -19,6 +23,32 @@ export function githubPrUrl(repository, prNumber) {
19
23
  return null;
20
24
  return `https://github.com/${owner}/${repo}/pull/${prNumber}`;
21
25
  }
26
+ async function eventHistory(jobId, taskId, limit) {
27
+ const boundedLimit = Math.max(1, Math.min(60, limit));
28
+ let content = '';
29
+ try {
30
+ content = await readFile(EVENTS_HISTORY_FILE, 'utf8');
31
+ }
32
+ catch {
33
+ return [];
34
+ }
35
+ const output = [];
36
+ const lines = content.split(/\r?\n/);
37
+ for (let index = lines.length - 1; index >= 0 && output.length < boundedLimit; index -= 1) {
38
+ const line = lines[index];
39
+ if (!line)
40
+ continue;
41
+ try {
42
+ const row = JSON.parse(line);
43
+ const event = row.event;
44
+ if (!event || event.job_id !== jobId || (taskId && event.task_id !== taskId))
45
+ continue;
46
+ output.push(event);
47
+ }
48
+ catch { /* ignore malformed historical rows */ }
49
+ }
50
+ return output;
51
+ }
22
52
  export const dashboardHtml = `<!doctype html>
23
53
  <html lang="en" data-dashboard-contract="safe-dom,named-relay-events,snapshot-events,bounded-event-history">
24
54
  <head>
@@ -179,6 +209,7 @@ let selectedJob=sessionStorage.getItem('agents-relay:selected-job');
179
209
  let selectedTaskId=sessionStorage.getItem('agents-relay:selected-task');
180
210
  let selectedRepo=sessionStorage.getItem('agents-relay:repo-filter')||'';
181
211
  let eventTaskId=selectedTaskId;
212
+ const displayedEventIds=new Set();
182
213
 
183
214
  function value(input){return input===null||input===undefined||input===''?'—':String(input);}
184
215
  function text(input){return document.createTextNode(value(input));}
@@ -189,12 +220,14 @@ function rememberSelection(){if(selectedJob)sessionStorage.setItem('agents-relay
189
220
  function selected(){return jobs.find(job=>job.id===selectedJob)||jobs[0]||null;}
190
221
  function counts(tasks){const out={};for(const task of Array.isArray(tasks)?tasks:[])out[task.state]=(out[task.state]||0)+1;return out;}
191
222
  function compactCounts(input){const order=['RUNNING','BLOCKED','FAILED','QUEUED','READY','WAITING','SUCCEEDED','CANCELLED'];return order.filter(state=>input[state]).map(state=>input[state]+' '+state).join(', ')||'No tasks';}
223
+ function awaitingInitialTask(job){return job?.state==='OPEN'&&Array.isArray(job?.tasks)&&job.tasks.length===0;}
192
224
  function relativeTime(timestamp){if(!timestamp)return '—';const parsed=Date.parse(timestamp);if(!Number.isFinite(parsed))return value(timestamp);const seconds=Math.max(0,Math.round((Date.now()-parsed)/1000));if(seconds<60)return 'just now';if(seconds<3600)return Math.floor(seconds/60)+'m ago';if(seconds<86400)return Math.floor(seconds/3600)+'h ago';return Math.floor(seconds/86400)+'d ago';}
193
225
  function safePrUrl(repository,prNumber){if(typeof repository!=='string'||!Number.isInteger(prNumber)||prNumber<=0)return null;const parts=repository.split('/');if(parts.length!==2)return null;const [owner,repo]=parts;if(!owner||!repo||owner==='.'||owner==='..'||repo==='.'||repo==='..')return null;if(!/^[A-Za-z0-9-]+$/.test(owner)||!/^[A-Za-z0-9._-]+$/.test(repo))return null;return 'https://github.com/'+owner+'/'+repo+'/pull/'+prNumber;}
194
226
  function prField(job){const box=element('div','detail-group');box.append(element('p','detail-label',text('GitHub PR')));const line=element('p','detail-value');const url=safePrUrl(job.repository,job.prNumber);if(url){const link=document.createElement('a');link.href=url;link.target='_blank';link.rel='noopener noreferrer';link.textContent='PR #'+job.prNumber;line.append(link);}else line.append(text('unavailable'));box.append(line);return box;}
195
227
  function chatField(task){const box=element('div','detail-group');box.append(element('p','detail-label',text('Chat')));const line=element('p','detail-value');if(task.adapter==='chatgpt'&&typeof task.threadId==='string'&&task.threadId){const link=document.createElement('a');link.href='https://chatgpt.com/c/'+encodeURIComponent(task.threadId);link.target='_blank';link.rel='noopener noreferrer';link.textContent='Open chat';line.append(link);}else line.append(text('—'));box.append(line);return box;}
196
- function clearEvents(){eventFeed.replaceChildren(element('li','empty',text('Waiting for events.')));}
197
- function selectJob(id){selectedJob=id;selectedTaskId=null;eventTaskId=null;rememberSelection();clearEvents();location.hash=id?'#/jobs/'+encodeURIComponent(id):'#/jobs';renderAll();}
228
+ function clearEvents(){displayedEventIds.clear();eventFeed.replaceChildren(element('li','empty',text('Waiting for events.')));}
229
+ async function loadEventHistory(){if(!selectedJob){clearEvents();return;}const params=new URLSearchParams({job:selectedJob,limit:String(MAX_EVENTS)});if(eventTaskId)params.set('task',eventTaskId);try{const history=await loadJSON('/api/events?'+params.toString());clearEvents();for(const event of Array.isArray(history)?history.slice().reverse():[])addEvent(event);}catch{clearEvents();}}
230
+ function selectJob(id){selectedJob=id;selectedTaskId=null;eventTaskId=null;rememberSelection();clearEvents();location.hash=id?'#/jobs/'+encodeURIComponent(id):'#/jobs';renderAll();void loadEventHistory();}
198
231
  function filteredJobs(){return jobs.filter(job=>!selectedRepo||job.repository===selectedRepo);}
199
232
  function syncFilters(){
200
233
  const repos=[...new Set(jobs.map(job=>job.repository).filter(Boolean))].sort();
@@ -203,12 +236,12 @@ function syncFilters(){
203
236
  selectedRepo=repos.includes(previousRepo)?previousRepo:'';
204
237
  repoFilter.value=selectedRepo;
205
238
  }
206
- 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));}}
207
- 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;eventTaskId=task.id;rememberSelection();clearEvents();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;}
239
+ 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')));if(awaitingInitialTask(job))meta.append(element('span','badge WAITING',text('AWAITING INITIAL TASK')));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));}}
240
+ 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;eventTaskId=task.id;rememberSelection();clearEvents();renderAll();void loadEventHistory();});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;}
208
241
  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));}
209
242
  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;}
210
243
  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);}
211
- 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;eventTaskId=null;clearEvents();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;eventTaskId=null;clearEvents();rememberSelection();}buildTree(job.tasks||[]);renderTaskDetail(selectedTask);renderEventScope();}
244
+ 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;eventTaskId=null;clearEvents();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('Status',awaitingInitialTask(job)?'Awaiting initial task':job.state),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;eventTaskId=null;clearEvents();rememberSelection();}buildTree(job.tasks||[]);renderTaskDetail(selectedTask);renderEventScope();}
212
245
  function renderAll(){jobs.sort(compareJobs);syncFilters();renderJobList();renderSelectedJob();}
213
246
  function isCompleteUnmerged(job){return job.state==='COMPLETED'&&job.githubState==='OPEN';}
214
247
  function isTerminalJob(job){return ['COMPLETED','CANCELLED'].includes(job.state)||['MERGED','CLOSED'].includes(job.githubState);}
@@ -222,9 +255,10 @@ function renderRateLimitStatus(status){
222
255
  rateLimitWarning.hidden=false;
223
256
  }
224
257
  async function refreshStatus(){try{renderRateLimitStatus(await loadJSON('/api/status'));}catch{renderRateLimitStatus(null);}}
225
- async function refresh(force=false){jobsRefresh.disabled=true;jobsRefresh.textContent='Refreshing…';try{const discovered=await loadJSON('/api/jobs'+(force?'?refresh=1':''));jobs=Array.isArray(discovered)?discovered:[];jobs.sort(compareJobs);if(selectedJob&&!jobs.some(job=>job.id===selectedJob)){selectedJob=null;selectedTaskId=null;}if(!selectedJob){const match=location.hash.match(/^#\\/jobs\\/(.+)$/);const fromHash=match?decodeURIComponent(match[1]):null;selectedJob=jobs.some(job=>job.id===fromHash)?fromHash:(jobs[0]?.id||null);}rememberSelection();renderAll();connection.textContent='Live · durable state connected';connection.className='status-pill ok';}catch(error){connection.textContent='Error · durable state unavailable';connection.className='status-pill bad';jobList.replaceChildren(element('li','error-state',text('Could not load jobs: '+error.message)));}finally{jobsRefresh.disabled=false;jobsRefresh.textContent='Refresh';}}
258
+ async function refresh(force=false){jobsRefresh.disabled=true;jobsRefresh.textContent='Refreshing…';try{const discovered=await loadJSON('/api/jobs'+(force?'?refresh=1':''));jobs=Array.isArray(discovered)?discovered:[];jobs.sort(compareJobs);if(selectedJob&&!jobs.some(job=>job.id===selectedJob)){selectedJob=null;selectedTaskId=null;}if(!selectedJob){const match=location.hash.match(/^#\\/jobs\\/(.+)$/);const fromHash=match?decodeURIComponent(match[1]):null;selectedJob=jobs.some(job=>job.id===fromHash)?fromHash:(jobs[0]?.id||null);}rememberSelection();renderAll();void loadEventHistory();connection.textContent='Live · durable state connected';connection.className='status-pill ok';}catch(error){connection.textContent='Error · durable state unavailable';connection.className='status-pill bad';jobList.replaceChildren(element('li','error-state',text('Could not load jobs: '+error.message)));}finally{jobsRefresh.disabled=false;jobsRefresh.textContent='Refresh';}}
226
259
  function renderEventScope(){const task=eventTaskId&&selected()?.tasks?.find(candidate=>candidate.id===eventTaskId);eventsAllTasks.disabled=!task;eventsAllTasks.setAttribute('aria-pressed',String(!task));eventsScope.textContent=task?'Task '+task.id+' · newest first · 60 max':'Job-wide · newest first · 60 max';}
227
- function addEvent(event,level){if(!event||selectedJob&&event.job_id&&event.job_id!==selectedJob||eventTaskId&&event.task_id!==eventTaskId)return;const item=element('li','event-item');const meta=element('div','event-meta');meta.append(element('span','event-kind',text(event.type||'event')),element('span','',text(relativeTime(event.timestamp))),element('span','',text('task: '+value(event.task_id))));item.append(meta);const message=element('p','event-message',text(event.message||'Event received'));if(level==='error'||event.level==='error')message.classList.add('error-text');item.append(message);eventFeed.querySelector('.empty')?.remove();eventFeed.prepend(item);while(eventFeed.children.length>MAX_EVENTS)eventFeed.lastElementChild?.remove();}
260
+ function eventLabel(type){return ({'task.process.launched':'Process launched','task.started':'Worker started','task.process.exited':'Sync process exited','task.process.async_exited':'Async process exited','task.completed':'Worker completed','task.failed':'Worker failed','task.blocked':'Worker blocked'})[type]||type||'Event';}
261
+ function addEvent(event,level){if(!event||selectedJob&&event.job_id&&event.job_id!==selectedJob||eventTaskId&&event.task_id!==eventTaskId)return;if(event.event_id&&displayedEventIds.has(event.event_id))return;if(event.event_id)displayedEventIds.add(event.event_id);const item=element('li','event-item');const meta=element('div','event-meta');meta.append(element('span','event-kind',text(eventLabel(event.type))),element('span','event-type',text(event.type||'event')),element('span','',text(relativeTime(event.timestamp))),element('span','',text('task: '+value(event.task_id))));item.append(meta);const message=element('p','event-message',text(event.message||'Event received'));if(level==='error'||event.level==='error')message.classList.add('error-text');item.append(message);eventFeed.querySelector('.empty')?.remove();eventFeed.prepend(item);while(eventFeed.children.length>MAX_EVENTS)eventFeed.lastElementChild?.remove();}
228
262
  function usageBar(label,windowData){const percent=Math.max(0,Math.min(100,100-windowData.usedPercent));const row=element('div','usage-row');const heading=element('div','usage-label');heading.append(element('span','',text(label)),element('span','',text(Math.round(percent)+'%')));const track=element('div','usage-track');track.setAttribute('role','progressbar');track.setAttribute('aria-label',label+' available');track.setAttribute('aria-valuemin','0');track.setAttribute('aria-valuemax','100');track.setAttribute('aria-valuenow',String(percent));const fill=element('div','usage-fill');fill.style.width=percent+'%';track.append(fill);row.append(heading,track);return row;}
229
263
  function usageProvider(provider){const card=element('section','usage-provider');card.append(element('h3','',text(provider.label)));const usage=provider.usage||{};if(!usage.available||!usage.fiveHour||!usage.weekly){card.append(element('p','empty',text(usage.reason||'Account usage telemetry is unavailable.')));return card;}const bars=element('div','usage-bars');bars.append(usageBar('5-hour remaining',usage.fiveHour),usageBar('Weekly remaining',usage.weekly));card.append(bars);return card;}
230
264
  function formatCredit(value,currency){if(value===null||value===undefined||!Number.isFinite(Number(value)))return '—';const number=Number(value);if(currency==='USD')return '$'+number.toFixed(2);if(currency==='CNY')return '¥'+number.toFixed(2);return number.toFixed(2)+' '+currency;}
@@ -233,7 +267,7 @@ async function drawUsage(){try{const overview=await loadJSON('/api/usage');usage
233
267
  function showView(view){const isJobs=view==='jobs';jobsView.hidden=!isJobs;jobsWorkspace.hidden=!isJobs;usageView.hidden=isJobs;jobsTab.setAttribute('aria-selected',String(isJobs));usageTab.setAttribute('aria-selected',String(!isJobs));if(isJobs)void refresh();else void drawUsage();}
234
268
  jobsTab.addEventListener('click',()=>{location.hash=selectedJob?'#/jobs/'+encodeURIComponent(selectedJob):'#/jobs';showView('jobs');});
235
269
  jobsRefresh.addEventListener('click',()=>void refresh(true));
236
- eventsAllTasks.addEventListener('click',()=>{eventTaskId=null;clearEvents();renderEventScope();});
270
+ eventsAllTasks.addEventListener('click',()=>{eventTaskId=null;clearEvents();renderEventScope();void loadEventHistory();});
237
271
  repoFilter.addEventListener('change',()=>{selectedRepo=repoFilter.value;sessionStorage.setItem('agents-relay:repo-filter',selectedRepo);renderJobList();});
238
272
  selectedJobRefresh.addEventListener('click',()=>void refreshSelected());
239
273
  usageTab.addEventListener('click',()=>{location.hash='#/usage';showView('usage');});
@@ -397,6 +431,21 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
397
431
  }
398
432
  return;
399
433
  }
434
+ if (new URL(req.url ?? '/', 'http://127.0.0.1').pathname === '/api/events') {
435
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1');
436
+ const requestedJobId = url.searchParams.get('job') ?? '';
437
+ const requestedTaskId = url.searchParams.get('task');
438
+ const requestedLimit = Number(url.searchParams.get('limit') ?? '60');
439
+ if (!/^[A-Za-z0-9_-]+$/.test(requestedJobId)) {
440
+ res.writeHead(400);
441
+ res.end('Invalid job id');
442
+ return;
443
+ }
444
+ const events = await eventHistory(requestedJobId, requestedTaskId, Number.isFinite(requestedLimit) ? requestedLimit : 60);
445
+ res.writeHead(200, { 'content-type': 'application/json' });
446
+ res.end(JSON.stringify(events));
447
+ return;
448
+ }
400
449
  if (req.url === '/api/status') {
401
450
  const status = statusProvider ? statusProvider() : { limited: false, cooldownUntil: null };
402
451
  res.writeHead(200, { 'content-type': 'application/json' });
package/dist/events.js CHANGED
@@ -51,4 +51,4 @@ export class NatsEventBus {
51
51
  return async () => { active = false; subscription.unsubscribe(); };
52
52
  }
53
53
  }
54
- export function eventFor(jobId, taskId, parentTaskId, type, status, message, visibility = 'orchestrator', data) { return { version: 1, event_id: randomUUID(), job_id: jobId, task_id: taskId, parent_task_id: parentTaskId, type, status, timestamp: new Date().toISOString(), visibility, level: status === 'failed' || status === 'blocked' ? 'error' : 'info', message, data }; }
54
+ export function eventFor(jobId, taskId, parentTaskId, type, status, message, visibility = 'orchestrator', data, source) { return { version: 1, event_id: randomUUID(), job_id: jobId, task_id: taskId, parent_task_id: parentTaskId, type, status, timestamp: new Date().toISOString(), visibility, level: status === 'failed' || status === 'blocked' ? 'error' : 'info', message, data, source }; }
package/dist/pool.js CHANGED
@@ -4,6 +4,9 @@ const RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
4
4
  export function compareJobPriority(a, b) { return RANK[a.priority ?? 'P2'] - RANK[b.priority ?? 'P2'] || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id); }
5
5
  export function runnableManagedJobs(items) { return items.filter(item => item.githubState === 'OPEN' && !item.draft && !['COMPLETED', 'CANCELLED'].includes(item.job.state)).map(item => item.job).sort(compareJobPriority); }
6
6
  export function needsLifecycleReconciliation(job, now = Date.now()) {
7
+ // Create-only jobs stay OPEN until a caller explicitly submits work.
8
+ if (job.tasks.length === 0)
9
+ return false;
7
10
  if (job.tasks.some(task => ['READY', 'QUEUED', 'WAITING'].includes(task.state)))
8
11
  return true;
9
12
  return job.tasks.some(task => task.state === 'RUNNING' && task.leaseExpiresAt !== null && new Date(task.leaseExpiresAt).getTime() <= now);
@@ -47,7 +50,7 @@ export class RepositoryWorkerPool {
47
50
  const store = new GitHubStore(this.client, job.repository, job.prNumber, this.trustedAuthors);
48
51
  const ready = job.tasks.filter(task => ['READY', 'QUEUED', 'WAITING'].includes(task.state)).length;
49
52
  const expiredRunning = job.tasks.some(task => task.state === 'RUNNING' && task.leaseExpiresAt !== null && new Date(task.leaseExpiresAt).getTime() <= Date.now());
50
- if (!needsLifecycleReconciliation(job) && job.tasks.length > 0)
53
+ if (!needsLifecycleReconciliation(job))
51
54
  continue;
52
55
  const allowance = Math.max(1, Math.min(capacity, ready || (expiredRunning ? 1 : 0) || 1));
53
56
  await this.makeReconciler(store, allowance).reconcile(job.id);
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { schedule, transitionTask } from './scheduler.js';
3
- import { LaunchFailure } from './adapters.js';
3
+ import { LaunchFailure, StartAckTimeout } from './adapters.js';
4
4
  import { eventFor } from './events.js';
5
5
  import { parsePlannerResult, plannerInput } from './planner.js';
6
6
  const PRIORITY_RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
@@ -230,25 +230,68 @@ export class Reconciler {
230
230
  task.executionId = randomUUID();
231
231
  const durableExecutionId = task.executionId;
232
232
  await this.store.saveTask(task);
233
- await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'task.started', 'running', `Task ${task.id} started`, 'user', { adapter: adapter.id, model: task.routing?.model }));
234
233
  const controller = new AbortController();
235
234
  let execution;
236
235
  const workerTask = { ...task, input: workerInput };
236
+ const startAckSetup = task.adapter === 'chatgpt' ? await this.startAck(job.id, task.id, Date.now()) : null;
237
+ const launchContext = startAckSetup ? { waitForWorkerStart: startAckSetup.ack } : {};
237
238
  try {
238
- execution = adapter.launch(workerTask, controller.signal);
239
+ execution = adapter.launch(workerTask, controller.signal, launchContext);
239
240
  }
240
241
  catch (error) {
242
+ await startAckSetup?.stop();
241
243
  await this.failLaunch(job.id, task.id, task.executionId, error instanceof Error ? error.message : String(error));
242
244
  return;
243
245
  }
246
+ void (execution.launched ?? Promise.resolve()).then(() => this.emit(eventFor(job.id, task.id, task.parentTaskId, 'task.process.launched', 'running', `Task ${task.id} process launched`, 'user', { adapter: adapter.id, model: task.routing?.model }, { agent: 'relay', component: 'agents-relay' }))).catch(error => this.failLaunch(job.id, task.id, durableExecutionId, error instanceof Error ? error.message : String(error), 'launch'));
247
+ if (startAckSetup)
248
+ void startAckSetup.ack.finally(() => { void startAckSetup.stop(); }).catch(() => { });
244
249
  this.live.set(task.id, { taskId: task.id, executionId: durableExecutionId, execution, controller });
245
250
  const terminalTimer = setTimeout(() => {
246
251
  this.detach(this.timeoutTask(job.id, task.id, durableExecutionId), job.id, task.id, 'worker terminal-event timeout');
247
252
  }, Math.max(1, task.timeoutMs));
248
253
  this.terminalTimers.set(task.id, terminalTimer);
249
- this.detach(execution.promise.then(result => this.runtimeSettled(job.id, task.id, durableExecutionId, result, null), error => error instanceof LaunchFailure
250
- ? this.failLaunch(job.id, task.id, durableExecutionId, error.message, 'delivery')
251
- : this.runtimeSettled(job.id, task.id, durableExecutionId, null, error instanceof Error ? error.message : String(error))), job.id, task.id, 'worker runtime completion');
254
+ this.detach(execution.promise.then(result => this.runtimeSettled(job.id, task.id, durableExecutionId, result, null), error => error instanceof StartAckTimeout
255
+ ? this.failLaunch(job.id, task.id, durableExecutionId, error.message, 'start-ack')
256
+ : error instanceof LaunchFailure
257
+ ? this.failLaunch(job.id, task.id, durableExecutionId, error.message, 'delivery')
258
+ : this.runtimeSettled(job.id, task.id, durableExecutionId, null, error instanceof Error ? error.message : String(error))), job.id, task.id, 'worker runtime completion');
259
+ }
260
+ async startAck(jobId, taskId, launchBarrier) {
261
+ if (!this.options.eventBus)
262
+ throw new Error('Worker start acknowledgement requires an event bus');
263
+ let resolveAck;
264
+ let rejectAck;
265
+ let settled = false;
266
+ let timer;
267
+ const ack = new Promise((resolve, reject) => { resolveAck = resolve; rejectAck = reject; });
268
+ const unsubscribe = await this.options.eventBus.subscribe(jobId, async (event) => {
269
+ if (settled || event.type !== 'task.started' || event.job_id !== jobId || event.task_id !== taskId)
270
+ return;
271
+ const sourceAgent = typeof event.source?.agent === 'string' ? event.source.agent : '';
272
+ const sourceComponent = typeof event.source?.component === 'string' ? event.source.component : '';
273
+ const workerOwned = event.data?.worker_owned === true || ['codex', 'chatgpt'].includes(sourceAgent);
274
+ if (!workerOwned || sourceAgent === 'relay' || sourceComponent === 'agents-relay')
275
+ return;
276
+ if (!event.timestamp || new Date(event.timestamp).getTime() < launchBarrier)
277
+ return;
278
+ settled = true;
279
+ if (timer)
280
+ clearTimeout(timer);
281
+ resolveAck();
282
+ });
283
+ timer = setTimeout(() => {
284
+ if (settled)
285
+ return;
286
+ settled = true;
287
+ void unsubscribe();
288
+ rejectAck(new StartAckTimeout(`Task ${taskId} worker_start_timeout: no worker task.started start-ack`));
289
+ }, this.options.startAckTimeoutMs ?? 60000);
290
+ let stopped = false;
291
+ const stop = async () => { if (stopped)
292
+ return; stopped = true; if (timer)
293
+ clearTimeout(timer); await unsubscribe(); };
294
+ return { ack, stop };
252
295
  }
253
296
  async launchPlanner(job, task) {
254
297
  const planner = this.options.planner;
@@ -352,7 +395,7 @@ export class Reconciler {
352
395
  const task = job.tasks.find(item => item.id === taskId);
353
396
  if (!task || task.executionId !== executionId || task.state !== 'RUNNING')
354
397
  return;
355
- const event = eventFor(jobId, taskId, task.parentTaskId, 'task.failed', 'failed', `Worker ${phase} failed: ${message}`, 'user', { phase });
398
+ const event = eventFor(jobId, taskId, task.parentTaskId, 'task.failed', 'failed', `Worker ${phase} failed: ${message}`, 'user', { phase, reason: phase === 'start-ack' ? 'worker_start_timeout' : undefined }, { agent: 'relay', component: 'agents-relay', watchdog: true });
356
399
  await this.applyTerminalEvent(event);
357
400
  await this.emit(event);
358
401
  }
@@ -364,7 +407,7 @@ export class Reconciler {
364
407
  const task = job.tasks.find(item => item.id === taskId);
365
408
  if (!task || task.executionId !== executionId || task.state !== 'RUNNING')
366
409
  return;
367
- await this.emit(eventFor(jobId, taskId, task.parentTaskId, error ? 'worker.runtime.failed' : 'worker.runtime.exited', error ? 'failed' : 'succeeded', error ?? result?.summary ?? 'Worker runtime exited', 'orchestrator', { authoritative: false, waitingForTerminalEvent: true }));
410
+ await this.emit(eventFor(jobId, taskId, task.parentTaskId, task.adapter === 'chatgpt' ? 'task.process.async_exited' : 'task.process.exited', error ? 'failed' : 'succeeded', error ?? result?.summary ?? 'Worker runtime exited', 'orchestrator', { authoritative: false, waitingForTerminalEvent: true, processError: error }, { agent: 'relay', component: 'agents-relay' }));
368
411
  }
369
412
  async timeoutTask(jobId, taskId, executionId) {
370
413
  const live = this.live.get(taskId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "1.0.15",
3
+ "version": "1.0.17",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -48,9 +48,12 @@ 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, closes its
52
- owned tab, and returns. ChatGPT continues the task independently and publishes
53
- the result through the declared output contract.
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`.
54
57
 
55
58
  The worker must never interact through a pre-existing user ChatGPT tab.
56
59
  Browser details, transient conversation identity, submission verification,
@@ -28,8 +28,11 @@ 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, close the worker-owned tab and return. Do not
32
- wait for the assistant response and do not reopen or poll the conversation.
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.
33
36
  - Treat any observed conversation/thread identity only as diagnostic evidence,
34
37
  never as a resumable handle.
35
38
  - Progress and terminal success/failure for the actual delegated task are
@@ -177,4 +177,16 @@ print(json.dumps({
177
177
  "diagnostic_thread_id": _diagnostic_thread_id(),
178
178
  "user_message_id": user_turn.get("id") or None,
179
179
  "verified": True,
180
- }, ensure_ascii=False))
180
+ }, ensure_ascii=False), flush=True)
181
+
182
+ # Keep the owned Temporary Chat tab alive until Relay observes the worker's
183
+ # post-launch task.started acknowledgement. The parent releases this file;
184
+ # atexit then closes only this worker-owned tab.
185
+ release_file = CFG.get("release_file")
186
+ if not release_file:
187
+ 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):
190
+ time.sleep(.1)
191
+ if not os.path.exists(release_file):
192
+ raise RuntimeError("worker start acknowledgement release was not observed")
@@ -16,6 +16,7 @@ def main() -> int:
16
16
  parser = argparse.ArgumentParser()
17
17
  parser.add_argument("--prompt", required=True)
18
18
  parser.add_argument("--file", action="append", default=[])
19
+ parser.add_argument("--release-file", required=True)
19
20
  args = parser.parse_args()
20
21
  with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as handle:
21
22
  json.dump(vars(args), handle, ensure_ascii=False)