agents-relay 2.0.58 → 2.0.60

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, pid: child.pid, 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;
@@ -0,0 +1,16 @@
1
+ /** One-shot CLI commands may have leaked transport handles after durable work ends. */
2
+ export async function runCliEntrypoint(main, options = {}) {
3
+ let exitCode = 0;
4
+ try {
5
+ await main();
6
+ }
7
+ catch (error) {
8
+ console.error(error instanceof Error ? error.message : String(error));
9
+ exitCode = 1;
10
+ }
11
+ process.exitCode = exitCode;
12
+ if (options.serve)
13
+ return;
14
+ const timer = setTimeout(() => (options.forceExit ?? process.exit)(exitCode), options.graceMs ?? 2_000);
15
+ timer.unref();
16
+ }
@@ -145,7 +145,20 @@ export async function withEventBus(args, work, resolve = eventBus) {
145
145
  return await work(bus);
146
146
  }
147
147
  finally {
148
- await bus?.close?.();
148
+ if (bus?.close) {
149
+ let timer;
150
+ const deadline = new Promise(resolve => { timer = setTimeout(resolve, 1_500); });
151
+ try {
152
+ await Promise.race([bus.close(), deadline]);
153
+ }
154
+ catch (error) {
155
+ process.stderr.write('warning: event bus close failed: ' + (error instanceof Error ? error.message : String(error)) + '\n');
156
+ }
157
+ finally {
158
+ if (timer)
159
+ clearTimeout(timer);
160
+ }
161
+ }
149
162
  }
150
163
  }
151
164
  export async function publishWake(bus, job, taskId, message) { if (!bus)
package/dist/cli.js CHANGED
@@ -14,11 +14,11 @@ import { CodexThreadContinuation, CommandContinuation, WebhookContinuation } fro
14
14
  import { eventFor } from './events.js';
15
15
  import { serveDashboard } from './dashboard.js';
16
16
  import { codexAndZaiUsageRegistry } from './usage.js';
17
- import { decisionMode, LayaCliDecisionEngine } from './decision.js';
17
+ import { decisionMode, executionDecisionEngine } from './decision.js';
18
18
  import { discoverAgents } from './registry.js';
19
19
  import { AgentObjectivePlanner, CommandObjectivePlanner } from './planner.js';
20
20
  import { transitionJobEvent } from './scheduler.js';
21
- import { applyOrchestratorStartAction, applyOrchestratorCompleteAction, applyTaskHoldAction, dispatchTaskAction } from './task-actions.js';
21
+ import { applyOrchestratorStartAction, applyOrchestratorCompleteAction, applyTaskHoldAction, applyTaskQueueAction, dispatchTaskAction } from './task-actions.js';
22
22
  import { applyJobActivateAction, applyJobCancelAction } from './job-actions.js';
23
23
  import { resolveExecutionContext } from './execution-context.js';
24
24
  import { defaultWorkspaceRoot, resolveManagedProjectPath, resolveRepositoryProjectPath } from './workspace.js';
@@ -29,6 +29,8 @@ 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 { runCliEntrypoint } from './cli-shutdown.js';
33
+ import { shutdownOwnedProcesses } from './owned-process.js';
32
34
  export { helpText } from './cli-help.js';
33
35
  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
36
  export { SERVICE_DEFAULTS, eventBus, executionMode, chatGptTabClosePolicy } from './cli-support.js';
@@ -164,7 +166,9 @@ async function runTaskCommand(action, args) {
164
166
  await runManagedTaskCommand(action, args);
165
167
  return;
166
168
  }
167
- const { store, job, localFile } = await storeFor(args);
169
+ const loaded = await storeFor(args);
170
+ const { store, localFile } = loaded;
171
+ const job = managed && action === 'update' ? await relayControlJob(args, loaded.job.id) : loaded.job;
168
172
  if (action === 'list') {
169
173
  console.log(JSON.stringify(job.tasks, null, 2));
170
174
  return;
@@ -240,6 +244,7 @@ async function runTaskCommand(action, args) {
240
244
  console.log(JSON.stringify(outputTask, null, 2));
241
245
  return;
242
246
  }
247
+ const expectedUpdatedAt = task.updatedAt;
243
248
  if (['RUNNING', 'UNBLOCKED', 'SUCCEEDED', 'CANCELLED'].includes(task.state))
244
249
  throw new Error(`Task ${id} is ${task.state}; durable execution fields are immutable in this state`);
245
250
  if (hasArg(args, '--adapter')) {
@@ -282,9 +287,13 @@ async function runTaskCommand(action, args) {
282
287
  if (hasArg(args, '--draft') && task.state === 'QUEUED')
283
288
  await applyTaskHoldAction(task);
284
289
  if (hasArg(args, '--queue')) {
285
- await store.saveTask(task);
286
- const result = await dispatchTaskAction(store, { id: 'task.queue', jobId: job.id, taskId: id });
287
- Object.assign(task, result.task);
290
+ if (managed)
291
+ await applyTaskQueueAction(job, task);
292
+ else {
293
+ await store.saveTask(task);
294
+ const result = await dispatchTaskAction(store, { id: 'task.queue', jobId: job.id, taskId: id });
295
+ Object.assign(task, result.task);
296
+ }
288
297
  }
289
298
  if (hasArg(args, '--max-attempts') && task.maxAttempts < task.attempt)
290
299
  throw new Error('--max-attempts cannot be less than the current attempt count');
@@ -308,6 +317,11 @@ async function runTaskCommand(action, args) {
308
317
  if (hasArg(args, '--reasoning') && !hasArg(args, '--timeout'))
309
318
  task.timeoutMs = taskOverallTimeoutMs(task.storySize ?? 'M', task.routing.reasoning);
310
319
  }
320
+ if (managed) {
321
+ const updated = await relayTaskCommand(args, { jobId: job.id, taskId: id, action: 'update', task, expectedUpdatedAt });
322
+ console.log(JSON.stringify(updated.tasks.find(item => item.id === id), null, 2));
323
+ return;
324
+ }
311
325
  task.updatedAt = new Date().toISOString();
312
326
  await store.saveTask(task);
313
327
  await saveLocal(localFile, job);
@@ -457,7 +471,7 @@ async function runJobCommand(action, args) {
457
471
  }
458
472
  export function runtimePlanner(args) { const plannerCommand = arg([...args], '--planner-command'); const modelRuntime = arg([...args], '--codex', 'codex'); return plannerCommand ? new CommandObjectivePlanner(plannerCommand) : new AgentObjectivePlanner(modelRuntime); }
459
473
  export function runtimeReviewer() { return { requirements: { capabilities: ['model'] } }; }
460
- export function runtime(store, args, bus, usageRegistry = codexAndZaiUsageRegistry()) { const emit = (event) => { process.stderr.write(`${JSON.stringify(event)}\n`); }; const modelRuntime = arg(args, '--codex', 'codex'); const policy = chatGptTabClosePolicy(args); const planner = runtimePlanner(args); return new Reconciler(store, { owner: arg(args, '--owner', `cli-${process.pid}`), maxConcurrent: Number(arg(args, '--concurrency', '4')), leaseMs: Number(arg(args, '--lease-ms', '300000')), adapters: [new CodexAdapter(modelRuntime), new ChatGptAdapter({ tabClosePolicy: policy })], continuations: [new CodexThreadContinuation(modelRuntime), new CommandContinuation(), new WebhookContinuation()], planner, usageSnapshotProvider: () => usageRegistry.snapshot(), decisionEngine: new LayaCliDecisionEngine(), decisionMode: decisionMode(), reviewer: runtimeReviewer(), eventBus: bus, emit, chatgptTabClosePolicy: policy, projectPathForJob: async (job) => resolveRepositoryProjectPath(await resolveManagedProjectPath(job.repository, arg(args, '--project-path'), arg(args, '--workspace-root') || defaultWorkspaceRoot()), job.projectPath) }); }
474
+ export function runtime(store, args, bus, usageRegistry = codexAndZaiUsageRegistry()) { const emit = (event) => { process.stderr.write(`${JSON.stringify(event)}\n`); }; const modelRuntime = arg(args, '--codex', 'codex'); const policy = chatGptTabClosePolicy(args); const planner = runtimePlanner(args); return new Reconciler(store, { owner: arg(args, '--owner', `cli-${process.pid}`), maxConcurrent: Number(arg(args, '--concurrency', '4')), leaseMs: Number(arg(args, '--lease-ms', '300000')), adapters: [new CodexAdapter(modelRuntime), new ChatGptAdapter({ tabClosePolicy: policy })], continuations: [new CodexThreadContinuation(modelRuntime), new CommandContinuation(), new WebhookContinuation()], planner, usageSnapshotProvider: () => usageRegistry.snapshot(), decisionEngine: executionDecisionEngine(), decisionMode: decisionMode(), reviewer: runtimeReviewer(), eventBus: bus, emit, chatgptTabClosePolicy: policy, projectPathForJob: async (job) => resolveRepositoryProjectPath(await resolveManagedProjectPath(job.repository, arg(args, '--project-path'), arg(args, '--workspace-root') || defaultWorkspaceRoot()), job.projectPath) }); }
461
475
  export function dashboardJobAction(store, reconciler, action) {
462
476
  return async (jobId) => {
463
477
  const job = await store.load(jobId);
@@ -520,7 +534,7 @@ export async function createService(args) {
520
534
  const serverClosed = new Promise((resolve, reject) => { dashboardServer.close(error => error ? reject(error) : resolve()); });
521
535
  dashboardServer.closeAllConnections();
522
536
  const watcherClosed = bus ? bus.close() : stopWatcher();
523
- stopping = Promise.all([watcherClosed, serverClosed]).then(() => undefined, error => { throw error; });
537
+ stopping = Promise.all([reconciler.close(), watcherClosed, serverClosed, shutdownOwnedProcesses('parent-shutdown')]).then(() => undefined, error => { throw error; });
524
538
  return stopping;
525
539
  }
526
540
  };
@@ -631,10 +645,17 @@ async function main() {
631
645
  if (command === 'plan') {
632
646
  const guidance = hasArg(args, '--prompt') || hasArg(args, '--prompt-file') ? await textOption(args, '--prompt', '--prompt-file') : undefined;
633
647
  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));
648
+ const reconciler = runtime(store, args, bus);
649
+ try {
650
+ const result = await reconciler.planJob(job.id, guidance?.trim() || undefined);
651
+ await saveLocal(localFile, result.job);
652
+ await publishWake(bus, result.job, result.round.id, `Planning round ${result.round.id} completed`);
653
+ console.log(JSON.stringify(result.round, null, 2));
654
+ }
655
+ finally {
656
+ await reconciler.close();
657
+ await shutdownOwnedProcesses('parent-shutdown');
658
+ }
638
659
  });
639
660
  return;
640
661
  }
@@ -679,15 +700,21 @@ async function main() {
679
700
  return;
680
701
  }
681
702
  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));
703
+ try {
704
+ await reconciler.reconcile(job.id);
705
+ await reconciler.idle();
706
+ const finalState = await store.load(job.id);
707
+ await saveLocal(localFile, finalState);
708
+ console.log(JSON.stringify(finalState, null, 2));
709
+ }
710
+ finally {
711
+ await reconciler.close();
712
+ await shutdownOwnedProcesses('parent-shutdown');
713
+ }
687
714
  });
688
715
  return;
689
716
  }
690
717
  throw new Error(usage);
691
718
  }
692
719
  if (isEntrypoint(import.meta.url))
693
- main().catch(error => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; });
720
+ void runCliEntrypoint(main, { serve: process.argv[2] === 'serve' });
@@ -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/dashboard.js CHANGED
@@ -280,6 +280,8 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
280
280
  let result;
281
281
  if (body.action === 'create' && body.task && resolvedActions?.taskCreate)
282
282
  result = await resolvedActions.taskCreate(requestedJobId, body.task);
283
+ else if (body.action === 'update' && isTaskId(requestedTaskId) && body.task && body.expectedUpdatedAt && resolvedActions?.taskUpdate)
284
+ result = await resolvedActions.taskUpdate(requestedJobId, requestedTaskId, body.task, body.expectedUpdatedAt);
283
285
  else if (body.action === 'start' && isTaskId(requestedTaskId) && resolvedActions?.taskStart)
284
286
  result = await resolvedActions.taskStart(requestedJobId, requestedTaskId);
285
287
  else if (body.action === 'retry' && isTaskId(requestedTaskId) && resolvedActions?.taskRetry)
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}`);
@@ -71,6 +71,54 @@ export function parseChoiceDecision(raw, contract) {
71
71
  result.probabilities = parsedProbabilities;
72
72
  return result;
73
73
  }
74
+ /** OpenRouter Decisions API adapter; the choice contract remains engine-independent. */
75
+ export class JevDecisionEngine {
76
+ options;
77
+ name = 'openrouter-jev';
78
+ constructor(options = {}) {
79
+ this.options = options;
80
+ }
81
+ async decide(request, contract) {
82
+ validateDecisionRequest(request, contract);
83
+ const apiKey = this.options.apiKey ?? process.env.OPENROUTER_API_KEY;
84
+ if (!apiKey)
85
+ throw new Error('OPENROUTER_API_KEY is required for Jev');
86
+ const choices = Object.fromEntries(contract.choices.map((choice, index) => [`route_${index + 1}`, choice]));
87
+ const started = Date.now();
88
+ const response = await (this.options.fetcher ?? fetch)(this.options.endpoint ?? 'https://openrouter.ai/api/alpha/decisions', {
89
+ method: 'POST',
90
+ headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
91
+ body: JSON.stringify({
92
+ model: this.options.model ?? 'typesafe/jev-1.13',
93
+ state: request.facts,
94
+ questions: { decision: { type: 'choice', instructions: contract.question, criteria: choices } },
95
+ }),
96
+ signal: AbortSignal.timeout(this.options.timeoutMs ?? 12_000),
97
+ });
98
+ if (!response.ok)
99
+ throw new Error(`Jev Decisions API returned HTTP ${response.status}`);
100
+ const raw = await response.json();
101
+ const envelope = raw;
102
+ const answer = envelope?.answers?.decision;
103
+ if (!answer || typeof answer.choice !== 'string' || !Object.hasOwn(choices, answer.choice)) {
104
+ throw new Error('Jev returned a choice outside the decision contract');
105
+ }
106
+ const mappedProbabilities = answer.probabilities && typeof answer.probabilities === 'object'
107
+ ? Object.fromEntries(Object.entries(answer.probabilities)
108
+ .filter(([id]) => Object.hasOwn(choices, id)).map(([id, p]) => [choices[id], p]))
109
+ : undefined;
110
+ return {
111
+ ...parseChoiceDecision({ choice: choices[answer.choice], probabilities: mappedProbabilities }, contract),
112
+ usage: raw.usage,
113
+ latencyMs: Date.now() - started,
114
+ model: raw.model,
115
+ };
116
+ }
117
+ }
118
+ /** Select router engine without changing caller-owned hard gates. */
119
+ export function executionDecisionEngine() {
120
+ return new JevDecisionEngine();
121
+ }
74
122
  /**
75
123
  * Stateless Laya invocation. The adapter materializes the caller's JSON state
76
124
  * and question contract as temporary files for `uvx laya-mlx predict`. It is
@@ -122,7 +170,7 @@ export class LayaCliDecisionEngine {
122
170
  }
123
171
  async run(extraArgs) {
124
172
  return await new Promise((resolve, reject) => {
125
- const child = spawn(this.command, [...this.args, ...extraArgs], { stdio: ['ignore', 'pipe', 'pipe'], env: this.env ?? process.env });
173
+ const child = spawnOwnedProcess(this.command, [...this.args, ...extraArgs], { invocation: 'decision.laya', spawn: { stdio: ['ignore', 'pipe', 'pipe'], env: this.env ?? process.env } });
126
174
  const stdout = [];
127
175
  const stderr = [];
128
176
  let settled = false;
@@ -137,11 +185,11 @@ export class LayaCliDecisionEngine {
137
185
  resolve(output ?? '');
138
186
  };
139
187
  const timer = setTimeout(() => {
140
- child.kill('SIGKILL');
188
+ void terminateOwnedProcess(child, 'timeout', 0);
141
189
  finish(new Error(`Laya CLI timed out after ${this.timeoutMs}ms`));
142
190
  }, this.timeoutMs);
143
- child.stdout.on('data', chunk => stdout.push(Buffer.from(chunk)));
144
- child.stderr.on('data', chunk => stderr.push(Buffer.from(chunk)));
191
+ child.stdout?.on('data', chunk => stdout.push(Buffer.from(chunk)));
192
+ child.stderr?.on('data', chunk => stderr.push(Buffer.from(chunk)));
145
193
  child.once('error', error => finish(error));
146
194
  child.once('close', code => {
147
195
  if (code !== 0) {
@@ -0,0 +1,35 @@
1
+ import { activeRelayLeaseOwner, captureProcessRecord, probeProcessRecord, stopOrphanedProcessGroup } from './process-identity.js';
2
+ /** Correlate Relay execution IDs with OS process identity in SQLite, never GitHub. */
3
+ export async function monitorExecutionProcess(store, jobId, taskId, executionId, owner, execution) {
4
+ if (!execution.pid || !store.recordExecutionProcess)
5
+ return;
6
+ const registration = (async () => {
7
+ const record = await captureProcessRecord(jobId, taskId, executionId, owner, execution.pid);
8
+ if (record)
9
+ await store.recordExecutionProcess?.(record);
10
+ })();
11
+ const closeRecord = async () => {
12
+ await registration;
13
+ await store.finishExecutionProcess?.(jobId, taskId, executionId);
14
+ };
15
+ void execution.promise.then(closeRecord, closeRecord).catch(() => {
16
+ // Recording failures are surfaced by the registration promise to the caller.
17
+ });
18
+ await registration;
19
+ }
20
+ /** Reuse only if the previous exact process has gone or safely been reaped. */
21
+ export async function reclaimStaleExecutionProcess(store, task, currentOwner) {
22
+ if (!task.executionId || !task.jobId || !store.loadExecutionProcess)
23
+ return true;
24
+ const record = await store.loadExecutionProcess(task.jobId, task.id, task.executionId);
25
+ if (task.leaseOwner && task.leaseOwner !== currentOwner && await activeRelayLeaseOwner(task.leaseOwner))
26
+ return false;
27
+ if (!record || record.endedAt)
28
+ return true;
29
+ const state = await probeProcessRecord(record);
30
+ if (state === 'gone' || state === 'reused')
31
+ return true;
32
+ if (state !== 'alive' || (task.leaseOwner && task.leaseOwner !== currentOwner && await activeRelayLeaseOwner(task.leaseOwner)))
33
+ return false;
34
+ return await stopOrphanedProcessGroup(record);
35
+ }
@@ -1,4 +1,5 @@
1
1
  import { transitionTaskEvent } from './scheduler.js';
2
+ import { reclaimStaleExecutionProcess } from './execution-process-tracker.js';
2
3
  async function currentGeneration(store, job, stale) {
3
4
  const task = (await store.load(job.id)).tasks.find(item => item.id === stale.id);
4
5
  if (!task)
@@ -23,14 +24,18 @@ export async function recoverExpiredExecutions(job, now, ports) {
23
24
  const recoverable = task && (task.state === 'RUNNING' || (task.state === 'QUEUED' && task.executionId !== null));
24
25
  if (!recoverable || task.leaseExpiresAt === null || new Date(task.leaseExpiresAt).getTime() > now)
25
26
  continue;
27
+ if (!await reclaimStaleExecutionProcess(ports.store, task, ports.owner))
28
+ continue;
26
29
  await recoverTask(task, 'lease.expired', 'lease_expired', ports);
27
30
  }
28
31
  }
29
32
  export async function recoverStartupOrphans(job, ports) {
30
- const candidates = job.tasks.filter(task => task.kind !== 'planner' && task.state === 'RUNNING' && task.executionId && task.leaseOwner && task.leaseOwner !== ports.owner && task.adapter !== 'chatgpt');
33
+ const candidates = job.tasks.filter(task => task.kind !== 'planner' && (task.state === 'RUNNING' || task.state === 'QUEUED') && task.executionId && task.leaseOwner && task.leaseOwner !== ports.owner && task.adapter !== 'chatgpt');
31
34
  for (const stale of candidates) {
32
35
  const task = await currentGeneration(ports.store, job, stale);
33
- if (!task || task.state !== 'RUNNING' || task.adapter === 'chatgpt' || !task.executionId || !task.leaseOwner || task.leaseOwner === ports.owner)
36
+ if (!task || !['RUNNING', 'QUEUED'].includes(task.state) || task.adapter === 'chatgpt' || !task.executionId || !task.leaseOwner || task.leaseOwner === ports.owner)
37
+ continue;
38
+ if (!await reclaimStaleExecutionProcess(ports.store, task, ports.owner))
34
39
  continue;
35
40
  await recoverTask(task, 'execution.orphaned', 'startup_orphaned', ports);
36
41
  }
@@ -17,11 +17,35 @@ export function executionTaskShape(taskFacts = {}) {
17
17
  : 'Small/medium tasks favor ChatGPT when Browser Workspace capacity is available.',
18
18
  };
19
19
  }
20
+ /** Explicit task capability tags override heuristic inference; legacy capabilities are additive. */
21
+ export function taskCapabilityFacts(taskFacts) {
22
+ const task = taskFacts.task && typeof taskFacts.task === 'object' ? taskFacts.task : taskFacts;
23
+ const explicit = task.taskCapabilities ?? task.task_capabilities;
24
+ const legacy = task.capabilities;
25
+ const tags = [...(Array.isArray(explicit) ? explicit : []), ...(Array.isArray(legacy) ? legacy : [])]
26
+ .filter((item) => typeof item === 'string' && item.trim().length > 0);
27
+ if (Array.isArray(explicit) && explicit.length > 0)
28
+ return [...new Set(tags)];
29
+ const description = [task.input, task.title, task.description, task.role].filter(item => typeof item === 'string').join(' ').toLowerCase();
30
+ if (/image generation|generate (an? )?image|生成图片|图片生成|生图/.test(description))
31
+ tags.push('image_generation');
32
+ if (/final review|final approval review|最终审核|最终审查|终审/.test(description))
33
+ tags.push('final_review');
34
+ if (/visual review|image review|视觉审核|图片审核|画面审核/.test(description))
35
+ tags.push('visual_review');
36
+ if (/vision|image understanding|图像理解|图片理解|看图/.test(description))
37
+ tags.push('vision');
38
+ return [...new Set(tags)];
39
+ }
40
+ function workerCapabilityFacts(worker) {
41
+ const specializations = worker.adapter === 'chatgpt' ? ['vision', 'image_generation', 'visual_review', 'final_review'] : ['coding', 'cli', 'filesystem'];
42
+ return [...new Set([...worker.capabilities, ...specializations])];
43
+ }
20
44
  export const EXECUTION_ROUTING_PRINCIPLES = [
21
45
  'Choose one complete executable route: worker plus provider/model/profile/reasoning.',
22
46
  'ChatGPT with MacBridge and Codex have broadly overlapping execution capabilities.',
23
47
  "Browser Workspace exhaustion is ChatGPT's primary hard execution constraint.",
24
- 'Codex is only the deterministic fallback when the decision engine fails.',
48
+ 'Codex is the deterministic fallback when Jev fails; no second decision model is invoked.',
25
49
  ];
26
50
  function sameModelRoute(candidate, route) {
27
51
  return (!route.provider || candidate.provider === route.provider)
@@ -110,7 +134,7 @@ async function decideStage(engine, name, facts, question, choices) {
110
134
  const decision = await engine.decide({ facts }, contract);
111
135
  if (decision.type !== 'choice')
112
136
  throw new Error('Execution router expected a choice result for ' + name);
113
- return { name, facts, contract, result: { choice: decision.choice, probabilities: decision.probabilities } };
137
+ return { name, facts, contract, result: { choice: decision.choice, probabilities: decision.probabilities, usage: decision.usage, latencyMs: decision.latencyMs, model: decision.model } };
114
138
  }
115
139
  function routeId(worker, model) {
116
140
  return worker.workerId + '::' + model.id + (model.reasoning ? '@' + model.reasoning : '');
@@ -265,10 +289,13 @@ export async function routeExecution(workers, requirements, options = {}) {
265
289
  source: isDefaultCandidate(route.model, discoveredDefaults.get(route.worker.workerId) ?? []) ? 'profile-default' : 'explicit-choice',
266
290
  }));
267
291
  const choiceLabels = routes.map((route, index) => executionChoiceLabel(route, effectiveChoices[index].default));
292
+ // Usage is keyed by worker/provider, not repeated for every route choice.
293
+ const usageByProvider = Object.fromEntries([...new Map(routes.map(route => [
294
+ route.worker.workerId + '::' + route.model.provider,
295
+ { worker: route.worker.workerId, provider: route.model.provider, ...codePlan(route.model) },
296
+ ])).entries()]);
268
297
  const facts = JSON.parse(JSON.stringify({
269
298
  context: {
270
- job: taskFacts.job ?? null,
271
- task: taskFacts.task ?? taskFacts,
272
299
  task_shape: {
273
300
  story_size: taskShape.storySize,
274
301
  sustained_work: taskShape.sustainedWork,
@@ -280,14 +307,19 @@ export async function routeExecution(workers, requirements, options = {}) {
280
307
  reviewer_diversity: taskFacts.reviewer_diversity,
281
308
  } : null,
282
309
  },
310
+ task_capabilities: taskCapabilityFacts(taskFacts),
311
+ task_worker_capabilities: (() => {
312
+ const task = taskFacts.task && typeof taskFacts.task === 'object' ? taskFacts.task : taskFacts;
313
+ return Array.isArray(task.workerCapabilities) ? task.workerCapabilities : [];
314
+ })(),
315
+ worker_capabilities: Object.fromEntries(workers.map(worker => [worker.workerId, workerCapabilityFacts(worker)])),
316
+ usage_by_provider: usageByProvider,
283
317
  runtime: {
284
318
  chatgpt_running: chatgptRunning,
285
319
  preferred_chatgpt_concurrency: preferredChatgptConcurrency,
286
320
  },
287
- worker_candidates: workerProviderFacts(routes),
288
- effective_choices: effectiveChoices,
289
321
  }));
290
- const question = appendRoutingInstruction('Choose one complete route from the merged choice list. Relay has already merged profile defaults with the user\'s explicit [choices] and applied every authoritative hard gate, so only these routes are legal. Do not invent, add, or substitute routes that are not listed.', routingInstruction);
322
+ const question = appendRoutingInstruction('User routing instructions have highest priority over default preferences, task size, runtime and resource signals. Candidate order is arbitrary and conveys no priority, ranking or preference. Do not favor earlier choices. Select an offered route that obeys the user instructions.\nUser routing instructions:\n' + routingInstruction, 'For vision, image generation, visual review, and final review tasks, prefer ChatGPT Worker when available and within capacity; for coding, CLI and filesystem tasks, prefer Codex. These are soft preferences subordinate to user rules. Choose exactly one listed route; do not invent routes.');
291
323
  let selected = fallback;
292
324
  let evidence = { mode: options.decisionMode ?? 'off', fallback: true };
293
325
  const decisionStages = [];
@@ -297,7 +329,7 @@ export async function routeExecution(workers, requirements, options = {}) {
297
329
  decisionStages.push(stage);
298
330
  const index = stage.result.choice ? choiceLabels.indexOf(stage.result.choice) : -1;
299
331
  if (index < 0)
300
- throw new Error('Laya selected a route outside the merged choice list');
332
+ throw new Error('Decision engine selected a route outside the merged choice list');
301
333
  selected = routes[index];
302
334
  evidence = {
303
335
  mode: options.decisionMode ?? 'active',
@@ -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 {
@@ -31,7 +31,7 @@ export async function redecideFailedTask(options) {
31
31
  rulesPath: options.rulesPath,
32
32
  auditStore: options.auditStore,
33
33
  auditTask: options.task,
34
- taskFacts: { kind: options.task.kind ?? 'work', capabilities: options.task.capabilities, prior_routing: options.task.routing ?? null, fresh_usage_generated_at: usage.generatedAt },
34
+ taskFacts: { kind: options.task.kind ?? 'work', capabilities: options.task.capabilities, taskCapabilities: options.task.taskCapabilities, workerCapabilities: options.task.workerCapabilities, input: options.task.input, prior_routing: options.task.routing ?? null, fresh_usage_generated_at: usage.generatedAt },
35
35
  priorDecisionId: options.task.routing?.decisionId,
36
36
  priorFailure: options.evidence,
37
37
  excludedRoutes: options.task.routing ? [{ provider: options.task.routing.provider, model: options.task.routing.model, profile: options.task.routing.profile }] : [],