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 +15 -34
- package/dist/cli-escalation.js +4 -4
- package/dist/cli-shutdown.js +16 -0
- package/dist/cli-support.js +14 -1
- package/dist/cli.js +45 -18
- package/dist/continuation.js +3 -3
- package/dist/dashboard.js +2 -0
- package/dist/decision.js +53 -5
- package/dist/execution-process-tracker.js +35 -0
- package/dist/execution-recovery.js +7 -2
- package/dist/execution-routing.js +40 -8
- package/dist/github-client.js +8 -8
- package/dist/model-redecision.js +1 -1
- package/dist/owned-process.js +170 -0
- package/dist/planner-runtime.js +3 -1
- package/dist/planner.js +11 -11
- package/dist/process-identity.js +83 -0
- package/dist/projected-store.js +8 -0
- package/dist/reconciler-review.js +52 -15
- package/dist/reconciler-runtime.js +25 -6
- package/dist/reconciler.js +1 -1
- package/dist/relay-store.js +33 -0
- package/dist/relayd.js +9 -5
- package/dist/task-update.js +52 -0
- package/dist/terminal-event-watchdog.js +11 -0
- package/dist/usage.js +16 -10
- package/dist/workspace.js +14 -5
- package/package.json +1 -1
- package/workflow/task-lifecycle.mmd +1 -0
package/dist/adapters.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
1
|
import { readFile, writeFile, unlink } from 'node:fs/promises';
|
|
3
2
|
import { join } from 'node:path';
|
|
4
3
|
import { pathToFileURL } from 'node:url';
|
|
5
4
|
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { spawnOwnedProcess, terminateOwnedProcess } from './owned-process.js';
|
|
6
6
|
export function buildCodexArgs(route, input) {
|
|
7
7
|
const args = ['exec', '--json'];
|
|
8
8
|
if (route.profile)
|
|
@@ -27,29 +27,6 @@ function parseThreadStarted(line) {
|
|
|
27
27
|
return undefined;
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
|
-
function signalChildTree(child, signal) {
|
|
31
|
-
if (child.pid && process.platform !== 'win32') {
|
|
32
|
-
try {
|
|
33
|
-
process.kill(-child.pid, signal);
|
|
34
|
-
return;
|
|
35
|
-
}
|
|
36
|
-
catch (error) {
|
|
37
|
-
if (error.code !== 'ESRCH')
|
|
38
|
-
throw error;
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
child.kill(signal);
|
|
42
|
-
}
|
|
43
|
-
function terminateChild(child, graceMs = 750) {
|
|
44
|
-
if (child.exitCode !== null || child.signalCode !== null)
|
|
45
|
-
return;
|
|
46
|
-
signalChildTree(child, 'SIGTERM');
|
|
47
|
-
const force = setTimeout(() => {
|
|
48
|
-
if (child.exitCode === null && child.signalCode === null)
|
|
49
|
-
signalChildTree(child, 'SIGKILL');
|
|
50
|
-
}, graceMs);
|
|
51
|
-
force.unref();
|
|
52
|
-
}
|
|
53
30
|
function appendTail(current, chunk, maxBytes = 16384) {
|
|
54
31
|
const combined = current + chunk;
|
|
55
32
|
return Buffer.byteLength(combined) <= maxBytes
|
|
@@ -67,11 +44,11 @@ export class CodexAdapter {
|
|
|
67
44
|
launch(task, signal, context = {}) {
|
|
68
45
|
if (!task.routing)
|
|
69
46
|
throw new Error(`Task ${task.id} requires routing metadata before Codex launch`);
|
|
70
|
-
const child =
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
47
|
+
const child = spawnOwnedProcess(this.command, buildCodexArgs(task.routing, task.input), {
|
|
48
|
+
invocation: `worker.codex:${task.id}`,
|
|
49
|
+
signal,
|
|
50
|
+
correlation: { jobId: task.jobId, taskId: task.id, executionId: task.executionId ?? undefined },
|
|
51
|
+
spawn: { shell: false, cwd: task.routing.cwd, stdio: ['ignore', 'pipe', 'pipe'] },
|
|
75
52
|
});
|
|
76
53
|
let stdoutBuffer = '';
|
|
77
54
|
let stderrTail = '';
|
|
@@ -104,7 +81,8 @@ export class CodexAdapter {
|
|
|
104
81
|
const promise = new Promise((resolve, reject) => {
|
|
105
82
|
child.on('error', error => { launchedReject(error); reject(error); });
|
|
106
83
|
child.once('spawn', launchedResolve);
|
|
107
|
-
child.on('close', code => {
|
|
84
|
+
child.on('close', (code, signal) => {
|
|
85
|
+
execution.processExit = { exitCode: code, signal, stderr: stderrTail };
|
|
108
86
|
if (stdoutBuffer)
|
|
109
87
|
consumeLine(stdoutBuffer);
|
|
110
88
|
if (code === 0) {
|
|
@@ -114,9 +92,8 @@ export class CodexAdapter {
|
|
|
114
92
|
reject(new Error(stderrTail.trim() || `Codex exited ${code}`));
|
|
115
93
|
}
|
|
116
94
|
});
|
|
117
|
-
signal.addEventListener('abort', () => terminateChild(child), { once: true });
|
|
118
95
|
});
|
|
119
|
-
execution = { id: randomUUID(), promise, launched, cancel: () =>
|
|
96
|
+
execution = { id: randomUUID(), promise, launched, 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 =
|
|
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 = () =>
|
|
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);
|
package/dist/cli-escalation.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
1
|
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
3
2
|
import { tmpdir } from 'node:os';
|
|
4
3
|
import { join } from 'node:path';
|
|
5
4
|
import { textOption, arg } from './cli-support.js';
|
|
6
5
|
import { runEscalationGraph } from './workflow/escalation.js';
|
|
6
|
+
import { spawnOwnedProcess } from './owned-process.js';
|
|
7
7
|
export function neoEscalationArgs(handoffPath, policyPath, cwd) {
|
|
8
8
|
return [
|
|
9
9
|
'escalation', '--handoff-file', handoffPath,
|
|
@@ -31,11 +31,11 @@ export async function runNeoEscalation(request) {
|
|
|
31
31
|
}
|
|
32
32
|
const args = neoEscalationArgs(handoffPath, policyPath, request.cwd);
|
|
33
33
|
return await new Promise((resolve, reject) => {
|
|
34
|
-
const child =
|
|
34
|
+
const child = spawnOwnedProcess('neo', args, { invocation: 'escalation.neo', spawn: { cwd: request.cwd ?? process.cwd(), stdio: ['ignore', 'pipe', 'pipe'] } });
|
|
35
35
|
let stdout = '';
|
|
36
36
|
let stderr = '';
|
|
37
|
-
child.stdout
|
|
38
|
-
child.stderr
|
|
37
|
+
child.stdout?.setEncoding('utf8').on('data', chunk => { stdout += chunk; });
|
|
38
|
+
child.stderr?.setEncoding('utf8').on('data', chunk => { stderr += chunk; });
|
|
39
39
|
child.on('error', reject);
|
|
40
40
|
child.on('close', code => {
|
|
41
41
|
let parsed;
|
|
@@ -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
|
+
}
|
package/dist/cli-support.js
CHANGED
|
@@ -145,7 +145,20 @@ export async function withEventBus(args, work, resolve = eventBus) {
|
|
|
145
145
|
return await work(bus);
|
|
146
146
|
}
|
|
147
147
|
finally {
|
|
148
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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:
|
|
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
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
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
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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
|
|
720
|
+
void runCliEntrypoint(main, { serve: process.argv[2] === 'serve' });
|
package/dist/continuation.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { spawnOwnedProcess } from './owned-process.js';
|
|
2
2
|
export class CodexThreadContinuation {
|
|
3
3
|
command;
|
|
4
4
|
kind = 'codex-thread';
|
|
@@ -6,12 +6,12 @@ export class CodexThreadContinuation {
|
|
|
6
6
|
this.command = command;
|
|
7
7
|
}
|
|
8
8
|
async continue(input, continuation) { if (continuation.kind !== this.kind)
|
|
9
|
-
throw new Error('Continuation is not a Codex thread'); await new Promise((resolve, reject) => { const child =
|
|
9
|
+
throw new Error('Continuation is not a Codex thread'); await new Promise((resolve, reject) => { const child = spawnOwnedProcess(this.command, ['exec', 'resume', continuation.target, input], { invocation: 'continuation.codex' }); child.on('error', reject); child.on('close', code => code === 0 ? resolve() : reject(new Error(`Codex continuation exited ${code}`))); }); }
|
|
10
10
|
}
|
|
11
11
|
export class CommandContinuation {
|
|
12
12
|
kind = 'command';
|
|
13
13
|
async continue(input, continuation) { if (continuation.kind !== this.kind)
|
|
14
|
-
throw new Error('Continuation is not a command'); await new Promise((resolve, reject) => { const child =
|
|
14
|
+
throw new Error('Continuation is not a command'); await new Promise((resolve, reject) => { const child = spawnOwnedProcess(continuation.target, [input], { invocation: 'continuation.command' }); child.on('error', reject); child.on('close', code => code === 0 ? resolve() : reject(new Error(`Continuation exited ${code}`))); }); }
|
|
15
15
|
}
|
|
16
16
|
export class WebhookContinuation {
|
|
17
17
|
kind = 'webhook';
|
package/dist/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 =
|
|
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
|
|
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
|
|
144
|
-
child.stderr
|
|
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
|
|
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
|
|
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('
|
|
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('
|
|
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',
|
package/dist/github-client.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
1
|
import { createSign } from 'node:crypto';
|
|
3
2
|
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
4
3
|
import { emptyGitHubRuntimeRateUsage, observeRuntimeRateLimit, parseGitHubCliHeaders, runtimeRejection } from './github-runtime-usage.js';
|
|
4
|
+
import { spawnOwnedProcess } from './owned-process.js';
|
|
5
5
|
function base64Url(value) {
|
|
6
6
|
return Buffer.from(value).toString('base64url');
|
|
7
7
|
}
|
|
@@ -143,15 +143,15 @@ export class GhApiClient {
|
|
|
143
143
|
this.assertAvailable();
|
|
144
144
|
const commandArgs = includeHeaders ? [args[0] ?? '', '--include', ...args.slice(1)] : args;
|
|
145
145
|
return await new Promise((resolve) => {
|
|
146
|
-
const child =
|
|
146
|
+
const child = spawnOwnedProcess('gh', commandArgs, { invocation: 'github.api', spawn: { env } });
|
|
147
147
|
let stdout = '';
|
|
148
148
|
let stderr = '';
|
|
149
|
-
child.stdout
|
|
150
|
-
child.stderr
|
|
149
|
+
child.stdout?.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
150
|
+
child.stderr?.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
151
151
|
child.on('error', error => resolve({ status: null, headers: {}, body: '', stderr: error.message }));
|
|
152
152
|
if (input !== undefined)
|
|
153
|
-
child.stdin
|
|
154
|
-
child.stdin
|
|
153
|
+
child.stdin?.write(input);
|
|
154
|
+
child.stdin?.end();
|
|
155
155
|
child.on('close', code => {
|
|
156
156
|
const response = parseGitHubCliHeaders(stdout, includeHeaders);
|
|
157
157
|
const failed = code !== 0;
|
|
@@ -231,9 +231,9 @@ export class GhApiClient {
|
|
|
231
231
|
}
|
|
232
232
|
async localLogin() {
|
|
233
233
|
return new Promise((resolve) => {
|
|
234
|
-
const child =
|
|
234
|
+
const child = spawnOwnedProcess('gh', ['auth', 'status', '--active', '--json', 'hosts'], { invocation: 'github.auth-status' });
|
|
235
235
|
let stdout = '';
|
|
236
|
-
child.stdout
|
|
236
|
+
child.stdout?.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
237
237
|
child.on('error', () => resolve(null));
|
|
238
238
|
child.on('close', () => {
|
|
239
239
|
try {
|
package/dist/model-redecision.js
CHANGED
|
@@ -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 }] : [],
|