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 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 ChatGPT worker-owned tab follows the configured close policy: `after-start` waits for the correlated start acknowledgement, `never` keeps it open for debug, and `after-terminal` waits for a new exact-task terminal event; start-ack failure closes only the owned tab after 60 seconds. The executing agent must publish exactly one correlated terminal event. Relay persists the matching durable state only from that terminal event. Model-backed tasks require an event bus and time out explicitly if no terminal event arrives.
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 `platforms/chatgpt` for all ChatGPT browser mechanics. It opens a fresh Temporary Chat tab, uses the account defaults, submits the prompt, verifies acceptance, and applies `--chatgpt-tab-close-policy` (or `AGENTS_RELAY_CHATGPT_TAB_CLOSE_POLICY`): `after-start` by default, `never` for test/debug, or `after-terminal` after a new exact-task terminal event. It never closes a user tab and does not poll for the assistant response, resume/reopen a thread, or use conversation text as the result channel. Any observed ChatGPT thread ID is diagnostic only.
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/session activity remains on the public Browser Workspace CLI (`session start/exec/stop` and platform commands). Relay does not create or delete workspaces through CLI lifecycle commands.
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, and tab/session close policy.
15
- - Execute only a one-shot submission. Do not expose a create/resume/status/result conversation lifecycle.
16
- - Always use a fresh worker-owned Browser Workspace session/tab. Never submit through a pre-existing user tab.
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
- - `after-start` releases the Browser Workspace session after the exact worker `task.started` barrier.
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 = spawn(this.command, buildCodexArgs(task.routing, task.input), {
71
- shell: false,
72
- cwd: task.routing.cwd,
73
- stdio: ['ignore', 'pipe', 'pipe'],
74
- detached: process.platform !== 'win32',
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: () => terminateChild(child) };
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 = spawn(cli.command, [...cli.prefix, ...args], { shell: false, stdio: [stdin === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'] });
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 sessionBrowserWorkerRunner() {
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 source = await browserWorkspaceCommand(cli, ['platform', 'source', 'chatgpt', 'temporary-submit', '--config', configFile]);
231
- const execOutput = await browserWorkspaceCommand(cli, ['session', 'exec', activeSessionId], source);
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
- catch (failure) {
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
- const cancel = () => {
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 ?? sessionBrowserWorkerRunner())(definition, { prompt: task.input, ...(task.attachments?.length ? { files: task.attachments } : {}), tabClosePolicy: policy }, route, controller.signal);
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 tab close policy requires a terminal event barrier');
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')
@@ -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 = spawn('neo', args, { cwd: request.cwd ?? process.cwd(), stdio: ['ignore', 'pipe', 'pipe'] });
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.setEncoding('utf8').on('data', chunk => { stdout += chunk; });
38
- child.stderr.setEncoding('utf8').on('data', chunk => { stderr += chunk; });
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 result = await runtime(store, args, bus).planJob(job.id, guidance?.trim() || undefined);
635
- await saveLocal(localFile, result.job);
636
- await publishWake(bus, result.job, result.round.id, `Planning round ${result.round.id} completed`);
637
- console.log(JSON.stringify(result.round, null, 2));
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
- await reconciler.reconcile(job.id);
683
- await reconciler.idle();
684
- const finalState = await store.load(job.id);
685
- await saveLocal(localFile, finalState);
686
- console.log(JSON.stringify(finalState, null, 2));
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
  }
@@ -1,4 +1,4 @@
1
- import { spawn } from 'node:child_process';
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 = spawn(this.command, ['exec', 'resume', continuation.target, input]); child.on('error', reject); child.on('close', code => code === 0 ? resolve() : reject(new Error(`Codex continuation exited ${code}`))); }); }
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 = spawn(continuation.target, [input]); child.on('error', reject); child.on('close', code => code === 0 ? resolve() : reject(new Error(`Continuation exited ${code}`))); }); }
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 = spawn(this.command, [...this.args, ...extraArgs], { stdio: ['ignore', 'pipe', 'pipe'], env: this.env ?? process.env });
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.kill('SIGKILL');
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.on('data', chunk => stdout.push(Buffer.from(chunk)));
144
- child.stderr.on('data', chunk => stderr.push(Buffer.from(chunk)));
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) {
@@ -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 = spawn('gh', commandArgs, { env });
146
+ const child = spawnOwnedProcess('gh', commandArgs, { invocation: 'github.api', spawn: { env } });
147
147
  let stdout = '';
148
148
  let stderr = '';
149
- child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
150
- child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
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.write(input);
154
- child.stdin.end();
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 = spawn('gh', ['auth', 'status', '--active', '--json', 'hosts']);
234
+ const child = spawnOwnedProcess('gh', ['auth', 'status', '--active', '--json', 'hosts'], { invocation: 'github.auth-status' });
235
235
  let stdout = '';
236
- child.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
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 = spawn(this.command, [], { stdio: ['pipe', 'pipe', 'pipe'] });
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.on('data', (chunk) => { stdout += chunk.toString(); });
391
- child.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
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
- signal.addEventListener('abort', () => child.kill('SIGTERM'), { once: true });
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 = spawn(this.command, ['exec', '--json', '--', prompt], { cwd: context.executionContext?.project.path, stdio: ['ignore', 'pipe', 'pipe'] });
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.on('data', (chunk) => {
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.on('data', (chunk) => { stderrTail = (stderrTail + chunk.toString()).slice(-16384); });
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 () => { if (stopped)
208
- return; stopped = true; if (timer)
209
- clearTimeout(timer); await unsubscribe(); };
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', error ?? result?.summary ?? 'Worker runtime exited', 'orchestrator', { authoritative: false, waitingForTerminalEvent: true, processError: error }, { agent: 'relay', component: 'agents-relay' }));
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 { spawn } from 'node:child_process';
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 = spawn(this.command, [], { stdio: ['pipe', 'pipe', 'ignore'] });
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.kill();
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.setEncoding('utf8');
35
- child.stdout.on('data', chunk => { output += chunk; });
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.end('import json\nprint(json.dumps(workspace_capacity()))\n');
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 = spawn('codex', ['app-server', '--stdio'], { stdio: ['pipe', 'pipe', 'ignore'] });
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
- const finish = (snapshot) => { lines.close(); child.kill(); resolve(snapshot); };
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.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { clientInfo: { name: 'agents-relay', version: '1' }, capabilities: {} } }) + '\n');
168
- setTimeout(() => child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'account/rateLimits/read', params: {} }) + '\n'), 100);
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 { execFile } from 'node:child_process';
5
- import { promisify } from 'node:util';
6
- const execFileAsync = promisify(execFile);
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 execFileAsync('git', ['-C', path, 'remote', 'get-url', 'origin'])).stdout.trim() || null;
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 execFileAsync('git', ['-C', path, ...args])).stdout.trim();
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']);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "2.0.57",
3
+ "version": "2.0.59",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {