agents-relay 1.0.16 → 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 +7 -1
- package/dist/adapters.js +53 -10
- package/dist/cli.js +72 -16
- package/dist/dashboard.js +5 -3
- package/dist/events.js +1 -1
- package/dist/pool.js +4 -1
- package/dist/reconciler.js +51 -8
- package/package.json +1 -1
- package/skills/chatgpt-browser-worker/SKILL.md +6 -3
- package/skills/chatgpt-browser-worker/agents/browser-worker.agent.md +5 -2
- package/skills/chatgpt-browser-worker/scripts/_temporary_bh.py +13 -1
- package/skills/chatgpt-browser-worker/scripts/temporary_bh.py +1 -0
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
493
|
-
|
|
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
|
@@ -220,6 +220,7 @@ function rememberSelection(){if(selectedJob)sessionStorage.setItem('agents-relay
|
|
|
220
220
|
function selected(){return jobs.find(job=>job.id===selectedJob)||jobs[0]||null;}
|
|
221
221
|
function counts(tasks){const out={};for(const task of Array.isArray(tasks)?tasks:[])out[task.state]=(out[task.state]||0)+1;return out;}
|
|
222
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;}
|
|
223
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';}
|
|
224
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;}
|
|
225
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;}
|
|
@@ -235,12 +236,12 @@ function syncFilters(){
|
|
|
235
236
|
selectedRepo=repos.includes(previousRepo)?previousRepo:'';
|
|
236
237
|
repoFilter.value=selectedRepo;
|
|
237
238
|
}
|
|
238
|
-
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));}}
|
|
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));}}
|
|
239
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;}
|
|
240
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));}
|
|
241
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;}
|
|
242
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);}
|
|
243
|
-
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();}
|
|
244
245
|
function renderAll(){jobs.sort(compareJobs);syncFilters();renderJobList();renderSelectedJob();}
|
|
245
246
|
function isCompleteUnmerged(job){return job.state==='COMPLETED'&&job.githubState==='OPEN';}
|
|
246
247
|
function isTerminalJob(job){return ['COMPLETED','CANCELLED'].includes(job.state)||['MERGED','CLOSED'].includes(job.githubState);}
|
|
@@ -256,7 +257,8 @@ function renderRateLimitStatus(status){
|
|
|
256
257
|
async function refreshStatus(){try{renderRateLimitStatus(await loadJSON('/api/status'));}catch{renderRateLimitStatus(null);}}
|
|
257
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';}}
|
|
258
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';}
|
|
259
|
-
function
|
|
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();}
|
|
260
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;}
|
|
261
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;}
|
|
262
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;}
|
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)
|
|
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);
|
package/dist/reconciler.js
CHANGED
|
@@ -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
|
|
250
|
-
? this.failLaunch(job.id, task.id, durableExecutionId, error.message, '
|
|
251
|
-
:
|
|
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,
|
|
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
|
@@ -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,
|
|
52
|
-
|
|
53
|
-
|
|
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,
|
|
32
|
-
|
|
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)
|