agents-relay 2.0.58 → 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/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
  }
@@ -153,10 +130,14 @@ function browserWorkspaceCli(override = process.env.AGENTS_RELAY_BROWSER_WORKSPA
153
130
  }
154
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 = '';
159
- const abort = () => terminateChild(child);
140
+ const abort = () => { void terminateOwnedProcess(child, 'abort'); };
160
141
  child.stdout?.on('data', (chunk) => { stdout += chunk.toString(); });
161
142
  child.stderr?.on('data', (chunk) => { stderr = appendTail(stderr, chunk.toString()); });
162
143
  child.on('error', reject);
@@ -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.58",
3
+ "version": "2.0.59",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {