agents-relay 2.0.57 → 2.0.59
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 +3 -3
- package/agents/chatgpt-worker.agent.md +4 -6
- package/dist/adapters.js +30 -101
- package/dist/cli-escalation.js +4 -4
- package/dist/cli.js +24 -10
- package/dist/continuation.js +3 -3
- package/dist/decision.js +5 -5
- package/dist/github-client.js +8 -8
- package/dist/owned-process.js +170 -0
- package/dist/planner.js +8 -10
- package/dist/reconciler-runtime.js +18 -4
- package/dist/relayd.js +2 -1
- package/dist/usage.js +16 -10
- package/dist/workspace.js +14 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -230,9 +230,9 @@ Managed tasks are descriptive work for agents, not shell commands. Use `codex` o
|
|
|
230
230
|
|
|
231
231
|
Every model-backed task declares a durable output with `--output task-pr` or `--output file --output-path PATH`. Agents Relay bootstraps workers with the managed PR URL, durable task-comment URL, job/task identity, project, output contract, and mandatory terminal-event contract; workers retrieve the task intent from GitHub rather than receiving a duplicate copy of the stored task prompt.
|
|
232
232
|
|
|
233
|
-
For Codex and ChatGPT, **correlated worker events are authoritative lifecycle inputs**, not a separate durable store. 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 subscribes, validates correlation, applies graph transitions, persists results in RelayStore, and projects them to GitHub. Relay publishes `task.process.launched` only after successful adapter launch; `task.started` is worker-owned and must be a new, correlated start acknowledgement. Codex publishes `task.process.exited` when its synchronous runtime exits, while ChatGPT publishes `task.process.async_exited` after its submission runtime exits; neither substitutes for `task.completed`, `task.failed`, `task.blocked`, or `task.cancelled`. The
|
|
233
|
+
For Codex and ChatGPT, **correlated worker events are authoritative lifecycle inputs**, not a separate durable store. 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 subscribes, validates correlation, applies graph transitions, persists results in RelayStore, and projects them to GitHub. 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 one-shot submission runtime exits; neither substitutes for `task.completed`, `task.failed`, `task.blocked`, or `task.cancelled`. 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.
|
|
234
234
|
|
|
235
|
-
The ChatGPT adapter uses Relay's `agents/chatgpt-worker.agent.md` lifecycle contract and Browser Workspace `
|
|
235
|
+
The ChatGPT adapter uses Relay's `agents/chatgpt-worker.agent.md` lifecycle contract and invokes Browser Workspace exactly as a one-shot platform action: `browser-workspace platform run chatgpt temporary-submit --auto-session --config <file>`. Browser Workspace owns creation/release of the fresh Temporary Chat session, selectors, attachments, and submit verification; Relay does not self-heal browser failures and instead lets delivery failures enter its normal retry/recovery lifecycle. Legacy `--chatgpt-tab-close-policy` values remain accepted for compatibility around Relay's event barriers, but they do not control the Browser Workspace auto-session lifetime. The adapter never uses a pre-existing user tab, polls for the assistant response, resumes/reopens a thread, or treats conversation text as the result channel. Any observed ChatGPT thread ID is diagnostic only.
|
|
236
236
|
|
|
237
237
|
~~~sh
|
|
238
238
|
npx agents-relay task create --repo OWNER/REPO --pr 5 --id job-1 \
|
|
@@ -283,4 +283,4 @@ For autonomous Jobs, `UNBLOCKED` is historical: Relay does not retry that old Ta
|
|
|
283
283
|
|
|
284
284
|
### Browser Workspace product boundary
|
|
285
285
|
|
|
286
|
-
Agents Relay owns the persistent `Relay` browser workspace as product infrastructure. At daemon startup it runs `browser-workspace status`, dynamically imports the returned absolute `admin_helper`, and calls `ensureWorkspace("Relay", 6)`. This bootstrap is best-effort: Browser Workspace discovery/helper failures are logged and skipped so they do not prevent `agents-relayd` from starting; ChatGPT browser work may remain unavailable until Browser Workspace recovers. Ordinary worker
|
|
286
|
+
Agents Relay owns the persistent `Relay` browser workspace as product infrastructure. At daemon startup it runs `browser-workspace status`, dynamically imports the returned absolute `admin_helper`, and calls `ensureWorkspace("Relay", 6)`. This bootstrap is best-effort: Browser Workspace discovery/helper failures are logged and skipped so they do not prevent `agents-relayd` from starting; ChatGPT browser work may remain unavailable until Browser Workspace recovers. Ordinary ChatGPT worker activity uses the public Browser Workspace one-shot `platform run ... --auto-session` command; orchestrator-owned self-healing flows may still use explicit session commands. Relay does not create or delete workspaces through CLI lifecycle commands.
|
|
@@ -11,14 +11,12 @@ You execute one complete Relay task by submitting it to a fresh worker-owned Cha
|
|
|
11
11
|
## Contract
|
|
12
12
|
|
|
13
13
|
- Browser Workspace `platforms/chatgpt` is the sole owner of ChatGPT selectors, readiness checks, attachments, Temporary Chat mechanics, and submit verification.
|
|
14
|
-
- Relay owns worker lifecycle, task/event barriers, routing, cancellation
|
|
15
|
-
- Execute only a one-shot submission
|
|
16
|
-
-
|
|
14
|
+
- Relay owns worker lifecycle, task/event barriers, routing, and cancellation; Browser Workspace owns the one-shot auto-session lifecycle.
|
|
15
|
+
- Execute only a one-shot submission with `platform run chatgpt temporary-submit --auto-session`. Do not expose a create/resume/status/result conversation lifecycle.
|
|
16
|
+
- Let Browser Workspace create and release the fresh Temporary Chat session/tab for the action. Never submit through a pre-existing user tab.
|
|
17
17
|
- Preserve the account's current default model/thinking settings.
|
|
18
18
|
- A submission is accepted only after Browser Workspace returns a verified submit receipt.
|
|
19
19
|
- Treat observed conversation/thread identity as diagnostic only, never as a resumable handle.
|
|
20
|
-
-
|
|
21
|
-
- `after-terminal` releases it after the exact task terminal barrier.
|
|
22
|
-
- `never` leaves the owned session open for test/debug.
|
|
20
|
+
- Legacy ChatGPT tab-close policy values remain accepted by Relay for compatibility, but they do not control Browser Workspace's auto-session lifetime or add self-heal.
|
|
23
21
|
- Do not wait for the assistant response. Durable task success/failure is event-owned by Relay and the executing task.
|
|
24
22
|
- Stop on authentication, MFA, consent, or ambiguous browser state rather than bypassing it.
|
package/dist/adapters.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
1
|
import { readFile, writeFile, unlink } from 'node:fs/promises';
|
|
3
2
|
import { join } from 'node:path';
|
|
4
3
|
import { pathToFileURL } from 'node:url';
|
|
5
4
|
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { spawnOwnedProcess, terminateOwnedProcess } from './owned-process.js';
|
|
6
6
|
export function buildCodexArgs(route, input) {
|
|
7
7
|
const args = ['exec', '--json'];
|
|
8
8
|
if (route.profile)
|
|
@@ -27,29 +27,6 @@ function parseThreadStarted(line) {
|
|
|
27
27
|
return undefined;
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
|
-
function signalChildTree(child, signal) {
|
|
31
|
-
if (child.pid && process.platform !== 'win32') {
|
|
32
|
-
try {
|
|
33
|
-
process.kill(-child.pid, signal);
|
|
34
|
-
return;
|
|
35
|
-
}
|
|
36
|
-
catch (error) {
|
|
37
|
-
if (error.code !== 'ESRCH')
|
|
38
|
-
throw error;
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
child.kill(signal);
|
|
42
|
-
}
|
|
43
|
-
function terminateChild(child, graceMs = 750) {
|
|
44
|
-
if (child.exitCode !== null || child.signalCode !== null)
|
|
45
|
-
return;
|
|
46
|
-
signalChildTree(child, 'SIGTERM');
|
|
47
|
-
const force = setTimeout(() => {
|
|
48
|
-
if (child.exitCode === null && child.signalCode === null)
|
|
49
|
-
signalChildTree(child, 'SIGKILL');
|
|
50
|
-
}, graceMs);
|
|
51
|
-
force.unref();
|
|
52
|
-
}
|
|
53
30
|
function appendTail(current, chunk, maxBytes = 16384) {
|
|
54
31
|
const combined = current + chunk;
|
|
55
32
|
return Buffer.byteLength(combined) <= maxBytes
|
|
@@ -67,11 +44,11 @@ export class CodexAdapter {
|
|
|
67
44
|
launch(task, signal, context = {}) {
|
|
68
45
|
if (!task.routing)
|
|
69
46
|
throw new Error(`Task ${task.id} requires routing metadata before Codex launch`);
|
|
70
|
-
const child =
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
47
|
+
const child = spawnOwnedProcess(this.command, buildCodexArgs(task.routing, task.input), {
|
|
48
|
+
invocation: `worker.codex:${task.id}`,
|
|
49
|
+
signal,
|
|
50
|
+
correlation: { jobId: task.jobId, taskId: task.id, executionId: task.executionId ?? undefined },
|
|
51
|
+
spawn: { shell: false, cwd: task.routing.cwd, stdio: ['ignore', 'pipe', 'pipe'] },
|
|
75
52
|
});
|
|
76
53
|
let stdoutBuffer = '';
|
|
77
54
|
let stderrTail = '';
|
|
@@ -104,7 +81,8 @@ export class CodexAdapter {
|
|
|
104
81
|
const promise = new Promise((resolve, reject) => {
|
|
105
82
|
child.on('error', error => { launchedReject(error); reject(error); });
|
|
106
83
|
child.once('spawn', launchedResolve);
|
|
107
|
-
child.on('close', code => {
|
|
84
|
+
child.on('close', (code, signal) => {
|
|
85
|
+
execution.processExit = { exitCode: code, signal, stderr: stderrTail };
|
|
108
86
|
if (stdoutBuffer)
|
|
109
87
|
consumeLine(stdoutBuffer);
|
|
110
88
|
if (code === 0) {
|
|
@@ -114,9 +92,8 @@ export class CodexAdapter {
|
|
|
114
92
|
reject(new Error(stderrTail.trim() || `Codex exited ${code}`));
|
|
115
93
|
}
|
|
116
94
|
});
|
|
117
|
-
signal.addEventListener('abort', () => terminateChild(child), { once: true });
|
|
118
95
|
});
|
|
119
|
-
execution = { id: randomUUID(), promise, launched, cancel: () =>
|
|
96
|
+
execution = { id: randomUUID(), promise, launched, cancel: () => { void terminateOwnedProcess(child, 'cancel'); } };
|
|
120
97
|
execution.threadId = bufferedThreadId;
|
|
121
98
|
return execution;
|
|
122
99
|
}
|
|
@@ -151,20 +128,32 @@ function parseDriverJson(output) {
|
|
|
151
128
|
function browserWorkspaceCli(override = process.env.AGENTS_RELAY_BROWSER_WORKSPACE_COMMAND) {
|
|
152
129
|
return override ? { command: override, prefix: [] } : { command: 'npx', prefix: ['browser-workspace'] };
|
|
153
130
|
}
|
|
154
|
-
function browserWorkspaceCommand(cli, args, stdin) {
|
|
131
|
+
function browserWorkspaceCommand(cli, args, stdin, signal) {
|
|
155
132
|
return new Promise((resolve, reject) => {
|
|
156
|
-
const child =
|
|
133
|
+
const child = spawnOwnedProcess(cli.command, [...cli.prefix, ...args], {
|
|
134
|
+
invocation: 'browser-workspace.command',
|
|
135
|
+
signal,
|
|
136
|
+
spawn: { shell: false, stdio: [stdin === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'] },
|
|
137
|
+
});
|
|
157
138
|
let stdout = '';
|
|
158
139
|
let stderr = '';
|
|
140
|
+
const abort = () => { void terminateOwnedProcess(child, 'abort'); };
|
|
159
141
|
child.stdout?.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
160
142
|
child.stderr?.on('data', (chunk) => { stderr = appendTail(stderr, chunk.toString()); });
|
|
161
143
|
child.on('error', reject);
|
|
162
144
|
child.on('close', code => {
|
|
145
|
+
signal?.removeEventListener('abort', abort);
|
|
163
146
|
if (code === 0)
|
|
164
147
|
resolve(stdout.trim());
|
|
148
|
+
else if (signal?.aborted)
|
|
149
|
+
reject(new Error('browser-worker execution aborted'));
|
|
165
150
|
else
|
|
166
151
|
reject(new Error(stderr.trim() || stdout.trim() || `browser-workspace exited ${code}`));
|
|
167
152
|
});
|
|
153
|
+
if (signal?.aborted)
|
|
154
|
+
abort();
|
|
155
|
+
else
|
|
156
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
168
157
|
if (stdin !== undefined) {
|
|
169
158
|
child.stdin?.end(stdin);
|
|
170
159
|
}
|
|
@@ -191,86 +180,26 @@ export async function ensureChatGptBrowserWorkspace(command = process.env.AGENTS
|
|
|
191
180
|
}
|
|
192
181
|
await admin.ensureWorkspace(CHATGPT_BROWSER_WORKSPACE, CHATGPT_BROWSER_WORKSPACE_POOL_SIZE);
|
|
193
182
|
}
|
|
194
|
-
function
|
|
183
|
+
function autoSessionBrowserWorkerRunner() {
|
|
195
184
|
const cli = browserWorkspaceCli();
|
|
196
185
|
return (_definition, request, _route, signal) => {
|
|
197
|
-
const policy = request.tabClosePolicy ?? 'after-start';
|
|
198
|
-
let activeSessionId;
|
|
199
|
-
let released = false;
|
|
200
|
-
let releaseResolve;
|
|
201
|
-
const releaseBarrier = new Promise(resolve => { releaseResolve = resolve; });
|
|
202
|
-
const release = () => {
|
|
203
|
-
if (released)
|
|
204
|
-
return;
|
|
205
|
-
released = true;
|
|
206
|
-
releaseResolve();
|
|
207
|
-
};
|
|
208
|
-
let submittedResolve;
|
|
209
|
-
let submittedReject;
|
|
210
|
-
const submitted = new Promise((resolve, reject) => {
|
|
211
|
-
submittedResolve = resolve;
|
|
212
|
-
submittedReject = reject;
|
|
213
|
-
});
|
|
214
186
|
const promise = (async () => {
|
|
215
187
|
const configFile = join(process.env.TMPDIR ?? '/tmp', `agents-relay-chatgpt-${randomUUID()}.json`);
|
|
216
188
|
try {
|
|
217
189
|
await writeFile(configFile, JSON.stringify({
|
|
218
190
|
prompt: request.prompt,
|
|
219
191
|
file: request.files ?? [],
|
|
220
|
-
close_policy: 'never',
|
|
221
192
|
}));
|
|
222
|
-
const startOutput = await browserWorkspaceCommand(cli, ['session', 'start', '--workspace', CHATGPT_BROWSER_WORKSPACE]);
|
|
223
|
-
const started = parseWorkspaceJson(startOutput);
|
|
224
|
-
if (typeof started.session_id !== 'string') {
|
|
225
|
-
throw new Error('browser-workspace session start returned no session_id');
|
|
226
|
-
}
|
|
227
|
-
activeSessionId = started.session_id;
|
|
228
193
|
if (signal.aborted)
|
|
229
194
|
throw new Error('browser-worker execution aborted');
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
const wrapper = parseWorkspaceJson(execOutput);
|
|
233
|
-
if (wrapper.ok !== true) {
|
|
234
|
-
throw new Error(typeof wrapper.error === 'string' ? wrapper.error : 'browser-workspace session exec failed');
|
|
235
|
-
}
|
|
236
|
-
const receipt = parseDriverJson(typeof wrapper.stdout === 'string' ? wrapper.stdout : '');
|
|
237
|
-
const result = JSON.stringify(receipt);
|
|
238
|
-
submittedResolve(result);
|
|
239
|
-
if (policy === 'never') {
|
|
240
|
-
await unlink(configFile).catch(() => { });
|
|
241
|
-
activeSessionId = undefined;
|
|
242
|
-
return result;
|
|
243
|
-
}
|
|
244
|
-
await releaseBarrier;
|
|
245
|
-
if (activeSessionId) {
|
|
246
|
-
await browserWorkspaceCommand(cli, ['session', 'stop', activeSessionId]);
|
|
247
|
-
activeSessionId = undefined;
|
|
248
|
-
}
|
|
249
|
-
return result;
|
|
195
|
+
const output = await browserWorkspaceCommand(cli, ['platform', 'run', 'chatgpt', 'temporary-submit', '--auto-session', '--config', configFile], undefined, signal);
|
|
196
|
+
return JSON.stringify(parseDriverJson(output));
|
|
250
197
|
}
|
|
251
|
-
|
|
252
|
-
if (activeSessionId) {
|
|
253
|
-
await browserWorkspaceCommand(cli, ['session', 'stop', activeSessionId]).catch(() => { });
|
|
254
|
-
activeSessionId = undefined;
|
|
255
|
-
}
|
|
198
|
+
finally {
|
|
256
199
|
await unlink(configFile).catch(() => { });
|
|
257
|
-
const error = failure instanceof Error ? failure : new Error(String(failure));
|
|
258
|
-
submittedReject(error);
|
|
259
|
-
throw error;
|
|
260
200
|
}
|
|
261
201
|
})();
|
|
262
|
-
|
|
263
|
-
release();
|
|
264
|
-
if (activeSessionId) {
|
|
265
|
-
void browserWorkspaceCommand(cli, ['session', 'stop', activeSessionId]);
|
|
266
|
-
activeSessionId = undefined;
|
|
267
|
-
}
|
|
268
|
-
};
|
|
269
|
-
if (signal.aborted)
|
|
270
|
-
cancel();
|
|
271
|
-
else
|
|
272
|
-
signal.addEventListener('abort', cancel, { once: true });
|
|
273
|
-
return { promise, submitted, release, cancel };
|
|
202
|
+
return { promise, submitted: promise, cancel: () => { } };
|
|
274
203
|
};
|
|
275
204
|
}
|
|
276
205
|
function parseBrowserWorkerResponse(output) {
|
|
@@ -326,7 +255,7 @@ export class ChatGptAdapter {
|
|
|
326
255
|
if (controller.signal.aborted)
|
|
327
256
|
throw new Error('browser-worker execution aborted');
|
|
328
257
|
const policy = chatGptTabClosePolicy(task.chatgptTabClosePolicy, this.tabClosePolicy);
|
|
329
|
-
const run = (this.runner ??
|
|
258
|
+
const run = (this.runner ?? autoSessionBrowserWorkerRunner())(definition, { prompt: task.input, ...(task.attachments?.length ? { files: task.attachments } : {}), tabClosePolicy: policy }, route, controller.signal);
|
|
330
259
|
try {
|
|
331
260
|
const output = await (run.submitted ?? run.promise);
|
|
332
261
|
const response = parseBrowserWorkerResponse(output);
|
|
@@ -335,7 +264,7 @@ export class ChatGptAdapter {
|
|
|
335
264
|
await context.waitForWorkerStart;
|
|
336
265
|
else if (policy === 'after-terminal') {
|
|
337
266
|
if (!context.waitForTerminal)
|
|
338
|
-
throw new Error('ChatGPT after-terminal
|
|
267
|
+
throw new Error('ChatGPT after-terminal compatibility policy requires a terminal event barrier');
|
|
339
268
|
await context.waitForTerminal;
|
|
340
269
|
}
|
|
341
270
|
if (policy !== 'never')
|
package/dist/cli-escalation.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
1
|
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
3
2
|
import { tmpdir } from 'node:os';
|
|
4
3
|
import { join } from 'node:path';
|
|
5
4
|
import { textOption, arg } from './cli-support.js';
|
|
6
5
|
import { runEscalationGraph } from './workflow/escalation.js';
|
|
6
|
+
import { spawnOwnedProcess } from './owned-process.js';
|
|
7
7
|
export function neoEscalationArgs(handoffPath, policyPath, cwd) {
|
|
8
8
|
return [
|
|
9
9
|
'escalation', '--handoff-file', handoffPath,
|
|
@@ -31,11 +31,11 @@ export async function runNeoEscalation(request) {
|
|
|
31
31
|
}
|
|
32
32
|
const args = neoEscalationArgs(handoffPath, policyPath, request.cwd);
|
|
33
33
|
return await new Promise((resolve, reject) => {
|
|
34
|
-
const child =
|
|
34
|
+
const child = spawnOwnedProcess('neo', args, { invocation: 'escalation.neo', spawn: { cwd: request.cwd ?? process.cwd(), stdio: ['ignore', 'pipe', 'pipe'] } });
|
|
35
35
|
let stdout = '';
|
|
36
36
|
let stderr = '';
|
|
37
|
-
child.stdout
|
|
38
|
-
child.stderr
|
|
37
|
+
child.stdout?.setEncoding('utf8').on('data', chunk => { stdout += chunk; });
|
|
38
|
+
child.stderr?.setEncoding('utf8').on('data', chunk => { stderr += chunk; });
|
|
39
39
|
child.on('error', reject);
|
|
40
40
|
child.on('close', code => {
|
|
41
41
|
let parsed;
|
package/dist/cli.js
CHANGED
|
@@ -29,6 +29,7 @@ import { helpText, usage } from './cli-help.js';
|
|
|
29
29
|
import { parseAgentGraph } from './agent-graph.js';
|
|
30
30
|
import { runJobUnblockCommand, runTaskUnblockCommand } from './cli-unblock.js';
|
|
31
31
|
import { createJobEntity, createTaskEntity } from './workflow/entity-creation.js';
|
|
32
|
+
import { shutdownOwnedProcesses } from './owned-process.js';
|
|
32
33
|
export { helpText } from './cli-help.js';
|
|
33
34
|
import { SERVICE_DEFAULTS, ServiceEventBus, arg, hasArg, textOption, integerArg, repository, storeFor, saveLocal, githubStore, eventBus, publishWake, publishDependentUnblocked, priority, storySize, executionMode, chatGptTabClosePolicy, requestedExecutionMode, continuation, scheduleFromArgs, jobTagFromArgs, requestManagedReconciliation, relayControlJob, relayTaskCommand, withEventBus } from './cli-support.js';
|
|
34
35
|
export { SERVICE_DEFAULTS, eventBus, executionMode, chatGptTabClosePolicy } from './cli-support.js';
|
|
@@ -520,7 +521,7 @@ export async function createService(args) {
|
|
|
520
521
|
const serverClosed = new Promise((resolve, reject) => { dashboardServer.close(error => error ? reject(error) : resolve()); });
|
|
521
522
|
dashboardServer.closeAllConnections();
|
|
522
523
|
const watcherClosed = bus ? bus.close() : stopWatcher();
|
|
523
|
-
stopping = Promise.all([watcherClosed, serverClosed]).then(() => undefined, error => { throw error; });
|
|
524
|
+
stopping = Promise.all([reconciler.close(), watcherClosed, serverClosed, shutdownOwnedProcesses('parent-shutdown')]).then(() => undefined, error => { throw error; });
|
|
524
525
|
return stopping;
|
|
525
526
|
}
|
|
526
527
|
};
|
|
@@ -631,10 +632,17 @@ async function main() {
|
|
|
631
632
|
if (command === 'plan') {
|
|
632
633
|
const guidance = hasArg(args, '--prompt') || hasArg(args, '--prompt-file') ? await textOption(args, '--prompt', '--prompt-file') : undefined;
|
|
633
634
|
await withEventBus(args, async (bus) => {
|
|
634
|
-
const
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
635
|
+
const reconciler = runtime(store, args, bus);
|
|
636
|
+
try {
|
|
637
|
+
const result = await reconciler.planJob(job.id, guidance?.trim() || undefined);
|
|
638
|
+
await saveLocal(localFile, result.job);
|
|
639
|
+
await publishWake(bus, result.job, result.round.id, `Planning round ${result.round.id} completed`);
|
|
640
|
+
console.log(JSON.stringify(result.round, null, 2));
|
|
641
|
+
}
|
|
642
|
+
finally {
|
|
643
|
+
await reconciler.close();
|
|
644
|
+
await shutdownOwnedProcesses('parent-shutdown');
|
|
645
|
+
}
|
|
638
646
|
});
|
|
639
647
|
return;
|
|
640
648
|
}
|
|
@@ -679,11 +687,17 @@ async function main() {
|
|
|
679
687
|
return;
|
|
680
688
|
}
|
|
681
689
|
const reconciler = runtime(store, args, bus);
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
690
|
+
try {
|
|
691
|
+
await reconciler.reconcile(job.id);
|
|
692
|
+
await reconciler.idle();
|
|
693
|
+
const finalState = await store.load(job.id);
|
|
694
|
+
await saveLocal(localFile, finalState);
|
|
695
|
+
console.log(JSON.stringify(finalState, null, 2));
|
|
696
|
+
}
|
|
697
|
+
finally {
|
|
698
|
+
await reconciler.close();
|
|
699
|
+
await shutdownOwnedProcesses('parent-shutdown');
|
|
700
|
+
}
|
|
687
701
|
});
|
|
688
702
|
return;
|
|
689
703
|
}
|
package/dist/continuation.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawnOwnedProcess } from './owned-process.js';
|
|
2
2
|
export class CodexThreadContinuation {
|
|
3
3
|
command;
|
|
4
4
|
kind = 'codex-thread';
|
|
@@ -6,12 +6,12 @@ export class CodexThreadContinuation {
|
|
|
6
6
|
this.command = command;
|
|
7
7
|
}
|
|
8
8
|
async continue(input, continuation) { if (continuation.kind !== this.kind)
|
|
9
|
-
throw new Error('Continuation is not a Codex thread'); await new Promise((resolve, reject) => { const child =
|
|
9
|
+
throw new Error('Continuation is not a Codex thread'); await new Promise((resolve, reject) => { const child = spawnOwnedProcess(this.command, ['exec', 'resume', continuation.target, input], { invocation: 'continuation.codex' }); child.on('error', reject); child.on('close', code => code === 0 ? resolve() : reject(new Error(`Codex continuation exited ${code}`))); }); }
|
|
10
10
|
}
|
|
11
11
|
export class CommandContinuation {
|
|
12
12
|
kind = 'command';
|
|
13
13
|
async continue(input, continuation) { if (continuation.kind !== this.kind)
|
|
14
|
-
throw new Error('Continuation is not a command'); await new Promise((resolve, reject) => { const child =
|
|
14
|
+
throw new Error('Continuation is not a command'); await new Promise((resolve, reject) => { const child = spawnOwnedProcess(continuation.target, [input], { invocation: 'continuation.command' }); child.on('error', reject); child.on('close', code => code === 0 ? resolve() : reject(new Error(`Continuation exited ${code}`))); }); }
|
|
15
15
|
}
|
|
16
16
|
export class WebhookContinuation {
|
|
17
17
|
kind = 'webhook';
|
package/dist/decision.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
1
|
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
3
2
|
import { tmpdir } from 'node:os';
|
|
4
3
|
import { join } from 'node:path';
|
|
4
|
+
import { spawnOwnedProcess, terminateOwnedProcess } from './owned-process.js';
|
|
5
5
|
function validateContract(contract) {
|
|
6
6
|
if (contract.type !== 'choice')
|
|
7
7
|
throw new Error(`Unsupported decision contract ${contract.type}`);
|
|
@@ -122,7 +122,7 @@ export class LayaCliDecisionEngine {
|
|
|
122
122
|
}
|
|
123
123
|
async run(extraArgs) {
|
|
124
124
|
return await new Promise((resolve, reject) => {
|
|
125
|
-
const child =
|
|
125
|
+
const child = spawnOwnedProcess(this.command, [...this.args, ...extraArgs], { invocation: 'decision.laya', spawn: { stdio: ['ignore', 'pipe', 'pipe'], env: this.env ?? process.env } });
|
|
126
126
|
const stdout = [];
|
|
127
127
|
const stderr = [];
|
|
128
128
|
let settled = false;
|
|
@@ -137,11 +137,11 @@ export class LayaCliDecisionEngine {
|
|
|
137
137
|
resolve(output ?? '');
|
|
138
138
|
};
|
|
139
139
|
const timer = setTimeout(() => {
|
|
140
|
-
child
|
|
140
|
+
void terminateOwnedProcess(child, 'timeout', 0);
|
|
141
141
|
finish(new Error(`Laya CLI timed out after ${this.timeoutMs}ms`));
|
|
142
142
|
}, this.timeoutMs);
|
|
143
|
-
child.stdout
|
|
144
|
-
child.stderr
|
|
143
|
+
child.stdout?.on('data', chunk => stdout.push(Buffer.from(chunk)));
|
|
144
|
+
child.stderr?.on('data', chunk => stderr.push(Buffer.from(chunk)));
|
|
145
145
|
child.once('error', error => finish(error));
|
|
146
146
|
child.once('close', code => {
|
|
147
147
|
if (code !== 0) {
|
package/dist/github-client.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
1
|
import { createSign } from 'node:crypto';
|
|
3
2
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
4
3
|
import { emptyGitHubRuntimeRateUsage, observeRuntimeRateLimit, parseGitHubCliHeaders, runtimeRejection } from './github-runtime-usage.js';
|
|
4
|
+
import { spawnOwnedProcess } from './owned-process.js';
|
|
5
5
|
function base64Url(value) {
|
|
6
6
|
return Buffer.from(value).toString('base64url');
|
|
7
7
|
}
|
|
@@ -143,15 +143,15 @@ export class GhApiClient {
|
|
|
143
143
|
this.assertAvailable();
|
|
144
144
|
const commandArgs = includeHeaders ? [args[0] ?? '', '--include', ...args.slice(1)] : args;
|
|
145
145
|
return await new Promise((resolve) => {
|
|
146
|
-
const child =
|
|
146
|
+
const child = spawnOwnedProcess('gh', commandArgs, { invocation: 'github.api', spawn: { env } });
|
|
147
147
|
let stdout = '';
|
|
148
148
|
let stderr = '';
|
|
149
|
-
child.stdout
|
|
150
|
-
child.stderr
|
|
149
|
+
child.stdout?.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
150
|
+
child.stderr?.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
151
151
|
child.on('error', error => resolve({ status: null, headers: {}, body: '', stderr: error.message }));
|
|
152
152
|
if (input !== undefined)
|
|
153
|
-
child.stdin
|
|
154
|
-
child.stdin
|
|
153
|
+
child.stdin?.write(input);
|
|
154
|
+
child.stdin?.end();
|
|
155
155
|
child.on('close', code => {
|
|
156
156
|
const response = parseGitHubCliHeaders(stdout, includeHeaders);
|
|
157
157
|
const failed = code !== 0;
|
|
@@ -231,9 +231,9 @@ export class GhApiClient {
|
|
|
231
231
|
}
|
|
232
232
|
async localLogin() {
|
|
233
233
|
return new Promise((resolve) => {
|
|
234
|
-
const child =
|
|
234
|
+
const child = spawnOwnedProcess('gh', ['auth', 'status', '--active', '--json', 'hosts'], { invocation: 'github.auth-status' });
|
|
235
235
|
let stdout = '';
|
|
236
|
-
child.stdout
|
|
236
|
+
child.stdout?.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
237
237
|
child.on('error', () => resolve(null));
|
|
238
238
|
child.on('close', () => {
|
|
239
239
|
try {
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { spawn, spawnSync, } from 'node:child_process';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { basename } from 'node:path';
|
|
4
|
+
import { observability } from './observability.js';
|
|
5
|
+
const owned = new Map();
|
|
6
|
+
const byChild = new WeakMap();
|
|
7
|
+
let exitHookInstalled = false;
|
|
8
|
+
function audit(record, event, outcome, data) {
|
|
9
|
+
observability().record({
|
|
10
|
+
stage: 'daemon',
|
|
11
|
+
event,
|
|
12
|
+
outcome,
|
|
13
|
+
level: outcome === 'failed' ? 'warn' : 'info',
|
|
14
|
+
message: `${record.invocation} process ${event.replace('process.', '')}`,
|
|
15
|
+
correlation: record.correlation,
|
|
16
|
+
data: {
|
|
17
|
+
invocationId: record.invocationId,
|
|
18
|
+
invocation: record.invocation,
|
|
19
|
+
executable: basename(record.command),
|
|
20
|
+
pid: record.pid,
|
|
21
|
+
processGroup: record.processGroup,
|
|
22
|
+
elapsedMs: Date.now() - record.startedAt,
|
|
23
|
+
...data,
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
function signalOwned(record, signal) {
|
|
28
|
+
try {
|
|
29
|
+
if (record.processGroup !== null)
|
|
30
|
+
process.kill(-record.processGroup, signal);
|
|
31
|
+
else
|
|
32
|
+
record.child.kill(signal);
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
if (error.code === 'ESRCH')
|
|
37
|
+
return false;
|
|
38
|
+
audit(record, 'process.signal_failed', 'failed', { signal, error: error instanceof Error ? error.message : String(error) });
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function forceOwned(record, reason) {
|
|
43
|
+
if (!owned.has(record.pid))
|
|
44
|
+
return;
|
|
45
|
+
if (process.platform === 'win32') {
|
|
46
|
+
const result = spawnSync('taskkill', ['/pid', String(record.pid), '/t', '/f'], { stdio: 'ignore', windowsHide: true });
|
|
47
|
+
audit(record, 'process.signal', result.status === 0 ? 'succeeded' : 'failed', { reason, signal: 'taskkill-/t-/f', exitStatus: result.status });
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const sent = signalOwned(record, 'SIGKILL');
|
|
51
|
+
audit(record, 'process.signal', sent ? 'succeeded' : 'failed', { reason, signal: 'SIGKILL' });
|
|
52
|
+
}
|
|
53
|
+
function installExitHook() {
|
|
54
|
+
if (exitHookInstalled)
|
|
55
|
+
return;
|
|
56
|
+
exitHookInstalled = true;
|
|
57
|
+
process.once('exit', () => {
|
|
58
|
+
for (const record of owned.values())
|
|
59
|
+
forceOwned(record, 'parent-shutdown');
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
function launchParentDeathSupervisor(pid, processGroup, graceMs) {
|
|
63
|
+
const posix = process.platform !== 'win32';
|
|
64
|
+
const script = posix ? [
|
|
65
|
+
"const pgid=Number(process.argv[1]),grace=Number(process.argv[2]);",
|
|
66
|
+
"const alive=()=>{try{process.kill(-pgid,0);return true}catch(e){return e&&e.code==='EPERM'}};",
|
|
67
|
+
"const stop=()=>{try{process.kill(-pgid,'SIGTERM')}catch{}setTimeout(()=>{try{process.kill(-pgid,'SIGKILL')}catch{}process.exit(0)},grace).unref()};",
|
|
68
|
+
"const timer=setInterval(()=>{if(!alive()){clearInterval(timer);process.exit(0);return}if(process.ppid===1){clearInterval(timer);stop()}},250);",
|
|
69
|
+
].join('') : [
|
|
70
|
+
"const {spawnSync}=require('node:child_process'),owner=Number(process.argv[1]),pid=Number(process.argv[2]);",
|
|
71
|
+
"const alive=p=>{try{process.kill(p,0);return true}catch{return false}};",
|
|
72
|
+
"const timer=setInterval(()=>{if(!alive(pid)){clearInterval(timer);process.exit(0);return}if(!alive(owner)){clearInterval(timer);spawnSync('taskkill',['/pid',String(pid),'/t','/f'],{stdio:'ignore'});process.exit(0)}},250);",
|
|
73
|
+
].join('');
|
|
74
|
+
const supervisorArgs = posix ? [String(processGroup), String(graceMs)] : [String(process.pid), String(pid)];
|
|
75
|
+
const supervisor = spawn(process.execPath, ['-e', script, ...supervisorArgs], {
|
|
76
|
+
detached: true,
|
|
77
|
+
stdio: 'ignore',
|
|
78
|
+
windowsHide: true,
|
|
79
|
+
});
|
|
80
|
+
supervisor.unref();
|
|
81
|
+
return supervisor;
|
|
82
|
+
}
|
|
83
|
+
/** Spawn one disposable Relay-owned process group. Arguments are intentionally excluded from audit logs. */
|
|
84
|
+
export function spawnOwnedProcess(command, args, options) {
|
|
85
|
+
installExitHook();
|
|
86
|
+
if (options.signal?.aborted)
|
|
87
|
+
throw new Error(`${options.invocation} aborted before launch`);
|
|
88
|
+
const detached = process.platform !== 'win32';
|
|
89
|
+
const child = spawn(command, [...args], { ...options.spawn, detached });
|
|
90
|
+
if (!child.pid)
|
|
91
|
+
return child;
|
|
92
|
+
const record = {
|
|
93
|
+
child,
|
|
94
|
+
invocationId: randomUUID(),
|
|
95
|
+
invocation: options.invocation,
|
|
96
|
+
command,
|
|
97
|
+
pid: child.pid,
|
|
98
|
+
processGroup: detached ? child.pid : null,
|
|
99
|
+
startedAt: Date.now(),
|
|
100
|
+
graceMs: options.graceMs ?? 750,
|
|
101
|
+
correlation: options.correlation,
|
|
102
|
+
terminating: false,
|
|
103
|
+
};
|
|
104
|
+
owned.set(record.pid, record);
|
|
105
|
+
byChild.set(child, record);
|
|
106
|
+
record.supervisor = launchParentDeathSupervisor(record.pid, record.processGroup, record.graceMs);
|
|
107
|
+
audit(record, 'process.launched', 'started', {});
|
|
108
|
+
const abort = () => { void terminateOwnedProcess(child, 'abort'); };
|
|
109
|
+
record.abort = abort;
|
|
110
|
+
options.signal?.addEventListener('abort', abort, { once: true });
|
|
111
|
+
if (options.timeoutMs !== undefined) {
|
|
112
|
+
record.timeoutTimer = setTimeout(() => { void terminateOwnedProcess(child, 'timeout'); }, options.timeoutMs);
|
|
113
|
+
record.timeoutTimer.unref();
|
|
114
|
+
}
|
|
115
|
+
child.once('exit', (code, signal) => {
|
|
116
|
+
audit(record, 'process.exited', code === 0 ? 'succeeded' : 'failed', { exitStatus: code, signal });
|
|
117
|
+
// `close` waits for inherited stdio. End any descendants left behind by a
|
|
118
|
+
// wrapper before they can keep the caller alive indefinitely.
|
|
119
|
+
if (!record.terminating)
|
|
120
|
+
void terminateOwnedProcess(child, 'leader-exit');
|
|
121
|
+
});
|
|
122
|
+
child.once('close', () => {
|
|
123
|
+
if (record.timeoutTimer)
|
|
124
|
+
clearTimeout(record.timeoutTimer);
|
|
125
|
+
options.signal?.removeEventListener('abort', abort);
|
|
126
|
+
// The leader closing is not proof that its process group is gone.
|
|
127
|
+
// In particular, unref'd grandchildren need not inherit stdio.
|
|
128
|
+
// Preserve ownership and the force-kill timer through the grace window.
|
|
129
|
+
if (!record.terminating)
|
|
130
|
+
void terminateOwnedProcess(child, 'leader-exit');
|
|
131
|
+
setTimeout(() => {
|
|
132
|
+
if (record.forceTimer)
|
|
133
|
+
clearTimeout(record.forceTimer);
|
|
134
|
+
record.supervisor?.kill('SIGTERM');
|
|
135
|
+
owned.delete(record.pid);
|
|
136
|
+
}, record.graceMs + 100).unref();
|
|
137
|
+
});
|
|
138
|
+
return child;
|
|
139
|
+
}
|
|
140
|
+
export async function terminateOwnedProcess(child, reason, graceMs) {
|
|
141
|
+
const record = byChild.get(child);
|
|
142
|
+
if (!record || !owned.has(record.pid))
|
|
143
|
+
return;
|
|
144
|
+
if (!record.terminating) {
|
|
145
|
+
record.terminating = true;
|
|
146
|
+
const sent = signalOwned(record, 'SIGTERM');
|
|
147
|
+
audit(record, 'process.signal', sent ? 'succeeded' : 'failed', { reason, signal: 'SIGTERM' });
|
|
148
|
+
record.forceTimer = setTimeout(() => forceOwned(record, reason), graceMs ?? record.graceMs);
|
|
149
|
+
record.forceTimer.unref();
|
|
150
|
+
}
|
|
151
|
+
if (child.exitCode !== null || child.signalCode !== null)
|
|
152
|
+
return;
|
|
153
|
+
await new Promise(resolve => {
|
|
154
|
+
const fallback = setTimeout(resolve, (graceMs ?? record.graceMs) + 1000);
|
|
155
|
+
fallback.unref();
|
|
156
|
+
child.once('close', () => { clearTimeout(fallback); resolve(); });
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
export async function shutdownOwnedProcesses(reason = 'parent-shutdown') {
|
|
160
|
+
await Promise.allSettled([...owned.values()].map(record => terminateOwnedProcess(record.child, reason)));
|
|
161
|
+
}
|
|
162
|
+
export function ownedProcessSnapshot() {
|
|
163
|
+
return [...owned.values()].map(record => ({
|
|
164
|
+
invocationId: record.invocationId,
|
|
165
|
+
invocation: record.invocation,
|
|
166
|
+
pid: record.pid,
|
|
167
|
+
processGroup: record.processGroup,
|
|
168
|
+
elapsedMs: Date.now() - record.startedAt,
|
|
169
|
+
}));
|
|
170
|
+
}
|
package/dist/planner.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
1
|
import { readFile } from 'node:fs/promises';
|
|
3
2
|
import { fileURLToPath } from 'node:url';
|
|
4
3
|
import { dirname, join } from 'node:path';
|
|
@@ -7,6 +6,7 @@ import { executionContextPrompt } from './execution-context.js';
|
|
|
7
6
|
import { parseAgentGraph, validateAgentSelection } from './agent-graph.js';
|
|
8
7
|
import { dispatchPlannerTrigger } from './workflow/planner-trigger-v2.js';
|
|
9
8
|
import { createTaskEntity } from './workflow/entity-creation.js';
|
|
9
|
+
import { spawnOwnedProcess } from './owned-process.js';
|
|
10
10
|
export function semanticTaskTree(tasks) {
|
|
11
11
|
const work = tasks.filter(task => task.kind !== 'planner');
|
|
12
12
|
const ids = new Set(work.map(task => task.id));
|
|
@@ -384,11 +384,11 @@ export class CommandObjectivePlanner {
|
|
|
384
384
|
}
|
|
385
385
|
async plan(context, signal) {
|
|
386
386
|
return await new Promise((resolve, reject) => {
|
|
387
|
-
const child =
|
|
387
|
+
const child = spawnOwnedProcess(this.command, [], { invocation: 'planner.command', signal, correlation: { jobId: context.job.id }, spawn: { stdio: ['pipe', 'pipe', 'pipe'] } });
|
|
388
388
|
let stdout = '';
|
|
389
389
|
let stderr = '';
|
|
390
|
-
child.stdout
|
|
391
|
-
child.stderr
|
|
390
|
+
child.stdout?.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
391
|
+
child.stderr?.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
392
392
|
child.on('error', reject);
|
|
393
393
|
child.on('close', code => code === 0 ? (() => { try {
|
|
394
394
|
resolve(parsePlannerOutput(stdout));
|
|
@@ -396,8 +396,7 @@ export class CommandObjectivePlanner {
|
|
|
396
396
|
catch (error) {
|
|
397
397
|
reject(error);
|
|
398
398
|
} })() : reject(new Error(stderr.trim() || `Planner exited ${code}`)));
|
|
399
|
-
|
|
400
|
-
child.stdin.end(plannerInput(context));
|
|
399
|
+
child.stdin?.end(plannerInput(context));
|
|
401
400
|
});
|
|
402
401
|
}
|
|
403
402
|
}
|
|
@@ -430,7 +429,7 @@ export class AgentObjectivePlanner {
|
|
|
430
429
|
const resolvedContext = context.executionContext ? executionContextPrompt(context.executionContext) + '\n\n' : '';
|
|
431
430
|
const prompt = instructions.trim() + '\n\n## Shared Task / Job Contract\n\n' + contract.trim() + '\n\n' + resolvedContext + '## Durable planner input\n\n' + plannerInput(context);
|
|
432
431
|
return await new Promise((resolve, reject) => {
|
|
433
|
-
const child =
|
|
432
|
+
const child = spawnOwnedProcess(this.command, ['exec', '--json', '--', prompt], { invocation: 'planner.agent', signal, correlation: { jobId: context.job.id }, spawn: { cwd: context.executionContext?.project.path, stdio: ['ignore', 'pipe', 'pipe'] } });
|
|
434
433
|
let stdoutBuffer = '';
|
|
435
434
|
let finalMessage = '';
|
|
436
435
|
let stderrTail = '';
|
|
@@ -439,14 +438,14 @@ export class AgentObjectivePlanner {
|
|
|
439
438
|
if (message)
|
|
440
439
|
finalMessage = message;
|
|
441
440
|
};
|
|
442
|
-
child.stdout
|
|
441
|
+
child.stdout?.on('data', (chunk) => {
|
|
443
442
|
stdoutBuffer += chunk.toString();
|
|
444
443
|
const lines = stdoutBuffer.split(/\r?\n/);
|
|
445
444
|
stdoutBuffer = lines.pop() ?? '';
|
|
446
445
|
for (const line of lines)
|
|
447
446
|
consume(line);
|
|
448
447
|
});
|
|
449
|
-
child.stderr
|
|
448
|
+
child.stderr?.on('data', (chunk) => { stderrTail = (stderrTail + chunk.toString()).slice(-16384); });
|
|
450
449
|
child.on('error', reject);
|
|
451
450
|
child.on('close', code => {
|
|
452
451
|
if (stdoutBuffer)
|
|
@@ -466,7 +465,6 @@ export class AgentObjectivePlanner {
|
|
|
466
465
|
reject(error);
|
|
467
466
|
}
|
|
468
467
|
});
|
|
469
|
-
signal.addEventListener('abort', () => child.kill('SIGTERM'), { once: true });
|
|
470
468
|
});
|
|
471
469
|
}
|
|
472
470
|
}
|
|
@@ -54,6 +54,7 @@ export class ReconcilerRuntimeBase {
|
|
|
54
54
|
continuationLive = new Set();
|
|
55
55
|
launchFailureClaims = new Set();
|
|
56
56
|
terminalWaiters = new Map();
|
|
57
|
+
startAckCleanups = new Set();
|
|
57
58
|
taskTransitions;
|
|
58
59
|
eventCoordinator;
|
|
59
60
|
detachedReconciles = new Set();
|
|
@@ -155,6 +156,7 @@ export class ReconcilerRuntimeBase {
|
|
|
155
156
|
await this.eventCoordinator.settle();
|
|
156
157
|
if (this.detachedReconciles.size > 0)
|
|
157
158
|
await Promise.allSettled([...this.detachedReconciles]);
|
|
159
|
+
await Promise.allSettled([...this.startAckCleanups].map(stop => stop()));
|
|
158
160
|
for (const live of this.live.values()) {
|
|
159
161
|
const sourceId = executionEventSource(live.jobId, live.taskId, live.executionId);
|
|
160
162
|
if (activeExecutionOwners.get(sourceId) === this)
|
|
@@ -204,9 +206,20 @@ export class ReconcilerRuntimeBase {
|
|
|
204
206
|
rejectAck(new StartAckTimeout(`Task ${taskId} worker_start_timeout: no worker task.started start-ack`));
|
|
205
207
|
}, timeoutMs);
|
|
206
208
|
let stopped = false;
|
|
207
|
-
const stop = async () => {
|
|
208
|
-
|
|
209
|
-
|
|
209
|
+
const stop = async () => {
|
|
210
|
+
if (stopped)
|
|
211
|
+
return;
|
|
212
|
+
stopped = true;
|
|
213
|
+
if (timer)
|
|
214
|
+
clearTimeout(timer);
|
|
215
|
+
if (!settled) {
|
|
216
|
+
settled = true;
|
|
217
|
+
rejectAck(new StartAckTimeout(`Task ${taskId} worker_start_cancelled: start-ack owner closed`));
|
|
218
|
+
}
|
|
219
|
+
this.startAckCleanups.delete(stop);
|
|
220
|
+
await unsubscribe();
|
|
221
|
+
};
|
|
222
|
+
this.startAckCleanups.add(stop);
|
|
210
223
|
return { ack, stop };
|
|
211
224
|
}
|
|
212
225
|
registerExecutionOwner(jobId, taskId, executionId) {
|
|
@@ -325,7 +338,8 @@ export class ReconcilerRuntimeBase {
|
|
|
325
338
|
return;
|
|
326
339
|
if (task.adapter !== 'chatgpt')
|
|
327
340
|
this.clearTerminalTimer(taskId, executionId);
|
|
328
|
-
await this.emit(eventFor(jobId, taskId, task.parentTaskId, task.adapter === 'chatgpt' ? 'task.process.async_exited' : 'task.process.exited', error ? 'failed' : 'succeeded',
|
|
341
|
+
await this.emit(eventFor(jobId, taskId, task.parentTaskId, task.adapter === 'chatgpt' ? 'task.process.async_exited' : 'task.process.exited', live?.execution.processExit ? (live.execution.processExit.exitCode === 0 ? 'succeeded' : 'failed') : (error ? 'failed' : 'succeeded'), result?.summary ?? (error ? 'Worker runtime exited with error' : 'Worker runtime exited'), 'orchestrator', { authoritative: false, waitingForTerminalEvent: true, exitCode: live?.execution.processExit?.exitCode ?? null,
|
|
342
|
+
signal: live?.execution.processExit?.signal ?? null, stderr: live?.execution.processExit?.stderr ?? '', processError: error }, { agent: 'relay', component: 'agents-relay' }));
|
|
329
343
|
}
|
|
330
344
|
async timeoutTask(jobId, taskId, executionId) {
|
|
331
345
|
const live = this.live.get(taskId);
|
package/dist/relayd.js
CHANGED
|
@@ -26,6 +26,7 @@ import { JsonlDecisionAuditStore } from './decision-audit.js';
|
|
|
26
26
|
import { summarizeRuntime } from './runtime-summary.js';
|
|
27
27
|
import { executionRouterRulesPath } from './routing-policy.js';
|
|
28
28
|
import { workerCandidates } from './worker-routing.js';
|
|
29
|
+
import { shutdownOwnedProcesses } from './owned-process.js';
|
|
29
30
|
import { routeExecution } from './execution-routing.js';
|
|
30
31
|
import { RelayObservability, classifyFailure, errorMessage, observability, setObservability } from './observability.js';
|
|
31
32
|
/** Initialize the process-wide structured observability sink once, from environment. */
|
|
@@ -508,7 +509,7 @@ export async function runDaemon(argv) {
|
|
|
508
509
|
const stop = () => { if (interval)
|
|
509
510
|
clearInterval(interval); if (scheduleInterval)
|
|
510
511
|
clearInterval(scheduleInterval); if (projectionInterval)
|
|
511
|
-
clearInterval(projectionInterval); void stopWatch().finally(() => { for (const store of relayStores.values())
|
|
512
|
+
clearInterval(projectionInterval); void Promise.allSettled([stopWatch(), shutdownOwnedProcesses('parent-shutdown')]).finally(() => { for (const store of relayStores.values())
|
|
512
513
|
store.close(); server.close(() => process.exit(0)); }); };
|
|
513
514
|
process.once('SIGINT', stop);
|
|
514
515
|
process.once('SIGTERM', stop);
|
package/dist/usage.js
CHANGED
|
@@ -3,7 +3,7 @@ import { readdir, stat } from 'node:fs/promises';
|
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { createInterface } from 'node:readline';
|
|
6
|
-
import {
|
|
6
|
+
import { spawnOwnedProcess, terminateOwnedProcess } from './owned-process.js';
|
|
7
7
|
export class BrowserWorkspaceUsageReader {
|
|
8
8
|
command;
|
|
9
9
|
timeoutMs;
|
|
@@ -18,7 +18,7 @@ export class BrowserWorkspaceUsageReader {
|
|
|
18
18
|
async read() {
|
|
19
19
|
const observedAt = this.now().toISOString();
|
|
20
20
|
return await new Promise(resolve => {
|
|
21
|
-
const child =
|
|
21
|
+
const child = spawnOwnedProcess(this.command, [], { invocation: 'usage.browser-workspace', timeoutMs: this.timeoutMs, spawn: { stdio: ['pipe', 'pipe', 'ignore'] } });
|
|
22
22
|
let output = '';
|
|
23
23
|
let settled = false;
|
|
24
24
|
const finish = (capacity) => {
|
|
@@ -26,13 +26,13 @@ export class BrowserWorkspaceUsageReader {
|
|
|
26
26
|
return;
|
|
27
27
|
settled = true;
|
|
28
28
|
clearTimeout(timer);
|
|
29
|
-
child
|
|
29
|
+
void terminateOwnedProcess(child, 'cancel');
|
|
30
30
|
resolve(capacity);
|
|
31
31
|
};
|
|
32
32
|
const unavailable = (reason) => ({ name: 'Harness', poolSize: null, idle: null, leased: null, available: null, observedAt, reason });
|
|
33
33
|
const timer = setTimeout(() => finish(unavailable('Browser Workspace capacity probe timed out.')), this.timeoutMs);
|
|
34
|
-
child.stdout
|
|
35
|
-
child.stdout
|
|
34
|
+
child.stdout?.setEncoding('utf8');
|
|
35
|
+
child.stdout?.on('data', chunk => { output += chunk; });
|
|
36
36
|
child.once('error', () => finish(unavailable('Browser Workspace capacity probe could not be started.')));
|
|
37
37
|
child.once('close', code => {
|
|
38
38
|
if (settled)
|
|
@@ -65,7 +65,7 @@ export class BrowserWorkspaceUsageReader {
|
|
|
65
65
|
}
|
|
66
66
|
finish(unavailable(code === 0 ? 'Browser Workspace capacity probe returned no usable capacity.' : `Browser Workspace capacity probe exited with code ${code ?? 'unknown'}.`));
|
|
67
67
|
});
|
|
68
|
-
child.stdin
|
|
68
|
+
child.stdin?.end('import json\nprint(json.dumps(workspace_capacity()))\n');
|
|
69
69
|
});
|
|
70
70
|
}
|
|
71
71
|
}
|
|
@@ -133,9 +133,15 @@ export class CodexApiUsageReader {
|
|
|
133
133
|
async read() {
|
|
134
134
|
const generatedAt = this.now().toISOString();
|
|
135
135
|
return await new Promise(resolve => {
|
|
136
|
-
const child =
|
|
136
|
+
const child = spawnOwnedProcess('codex', ['app-server', '--stdio'], { invocation: 'usage.codex-app-server', timeoutMs: 10_000, spawn: { stdio: ['pipe', 'pipe', 'ignore'] } });
|
|
137
|
+
if (!child.stdout) {
|
|
138
|
+
resolve({ available: false, generatedAt, fiveHour: null, weekly: null, reason: 'Codex app-server stdout unavailable.' });
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
137
141
|
const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
|
|
138
|
-
|
|
142
|
+
let queryTimer;
|
|
143
|
+
const finish = (snapshot) => { lines.close(); if (queryTimer)
|
|
144
|
+
clearTimeout(queryTimer); void terminateOwnedProcess(child, 'cancel'); resolve(snapshot); };
|
|
139
145
|
const timer = setTimeout(() => finish({ available: false, generatedAt, fiveHour: null, weekly: null, reason: 'Codex app-server rate-limit request timed out.' }), 10_000);
|
|
140
146
|
lines.on('line', line => {
|
|
141
147
|
try {
|
|
@@ -164,8 +170,8 @@ export class CodexApiUsageReader {
|
|
|
164
170
|
}
|
|
165
171
|
});
|
|
166
172
|
child.once('error', () => { clearTimeout(timer); finish({ available: false, generatedAt, fiveHour: null, weekly: null, reason: 'Codex app-server could not be started.' }); });
|
|
167
|
-
child.stdin
|
|
168
|
-
setTimeout(() => child.stdin
|
|
173
|
+
child.stdin?.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { clientInfo: { name: 'agents-relay', version: '1' }, capabilities: {} } }) + '\n');
|
|
174
|
+
queryTimer = setTimeout(() => child.stdin?.write(JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'account/rateLimits/read', params: {} }) + '\n'), 100);
|
|
169
175
|
});
|
|
170
176
|
}
|
|
171
177
|
}
|
package/dist/workspace.js
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
import { mkdir, readdir, stat } from 'node:fs/promises';
|
|
2
2
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
import { spawnOwnedProcess } from './owned-process.js';
|
|
5
|
+
async function gitOutput(args) {
|
|
6
|
+
return await new Promise((resolve, reject) => {
|
|
7
|
+
const child = spawnOwnedProcess('git', args, { invocation: 'workspace.git', spawn: { stdio: ['ignore', 'pipe', 'pipe'] } });
|
|
8
|
+
let stdout = '';
|
|
9
|
+
let stderr = '';
|
|
10
|
+
child.stdout?.on('data', chunk => { stdout += chunk.toString(); });
|
|
11
|
+
child.stderr?.on('data', chunk => { stderr += chunk.toString(); });
|
|
12
|
+
child.once('error', reject);
|
|
13
|
+
child.once('close', code => code === 0 ? resolve(stdout) : reject(new Error(stderr.trim() || `git exited ${code}`)));
|
|
14
|
+
});
|
|
15
|
+
}
|
|
7
16
|
export function defaultWorkspaceRoot() {
|
|
8
17
|
return join(homedir(), 'Workspace');
|
|
9
18
|
}
|
|
@@ -42,7 +51,7 @@ export async function discoverGitRepositories(root) {
|
|
|
42
51
|
}
|
|
43
52
|
async function origin(path) {
|
|
44
53
|
try {
|
|
45
|
-
return (await
|
|
54
|
+
return (await gitOutput(['-C', path, 'remote', 'get-url', 'origin'])).trim() || null;
|
|
46
55
|
}
|
|
47
56
|
catch {
|
|
48
57
|
return null;
|
|
@@ -111,7 +120,7 @@ export function taskUsesCanonicalProjectRun(task, tasks) {
|
|
|
111
120
|
return false;
|
|
112
121
|
}
|
|
113
122
|
async function git(path, args) {
|
|
114
|
-
return (await
|
|
123
|
+
return (await gitOutput(['-C', path, ...args])).trim();
|
|
115
124
|
}
|
|
116
125
|
export async function prepareManagedExecutionWorktree(projectPath, repository, prNumber, head, executionId) {
|
|
117
126
|
const primary = await git(projectPath, ['rev-parse', '--show-toplevel']);
|