orizu 0.5.48 → 0.5.49
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/hosted-optimization.d.ts.map +1 -1
- package/dist/hosted-optimization.js +40 -21
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +83 -68
- package/dist/runner-folders.d.ts +61 -0
- package/dist/runner-folders.d.ts.map +1 -0
- package/dist/runner-folders.js +222 -0
- package/dist/runner-version-materialization.d.ts +1 -1
- package/dist/runner-version-materialization.d.ts.map +1 -1
- package/dist/runner-version-materialization.js +20 -38
- package/dist/skilled-proposer-launch.d.ts +11 -1
- package/dist/skilled-proposer-launch.d.ts.map +1 -1
- package/dist/skilled-proposer-launch.js +35 -4
- package/package.json +1 -1
- package/scripts/build-cli-dist.mjs +46 -8
- package/scripts/vendor-orizu-skill.mjs +52 -7
- package/src/hosted-optimization.ts +49 -31
- package/src/index.ts +79 -87
- package/src/runner-folders.ts +223 -0
- package/src/runner-version-materialization.ts +21 -34
- package/src/skilled-proposer-launch.ts +41 -3
|
@@ -1,13 +1,6 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
2
1
|
import { spawnSync } from 'node:child_process';
|
|
3
|
-
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
-
import { tmpdir } from 'node:os';
|
|
5
|
-
import { join } from 'node:path';
|
|
6
|
-
import { ARTIFACT_MAX_BYTES } from './artifact-pull.js';
|
|
7
|
-
import { cleanupAll, throwWithCleanup } from './cleanup.js';
|
|
8
|
-
import { authedFetch } from './http.js';
|
|
9
2
|
import { sanitizeTerminalText } from './json-response.js';
|
|
10
|
-
function
|
|
3
|
+
export function unzipRunnerArchive(zipPath, runnerDir) {
|
|
11
4
|
const listing = spawnSync('unzip', ['-Z1', zipPath], { encoding: 'utf8' });
|
|
12
5
|
if (listing.error)
|
|
13
6
|
throw listing.error;
|
|
@@ -27,38 +20,27 @@ function unzip(zipPath, runnerDir) {
|
|
|
27
20
|
throw new Error(`unzip failed: ${sanitizeTerminalText(result.stderr || result.stdout || '')}`);
|
|
28
21
|
}
|
|
29
22
|
export async function materializeRunnerVersion(runnerVersionId, verifyRegisteredDigest = true) {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
if (detail.runnerVersionId !== runnerVersionId || detail.status !== 'sealed'
|
|
37
|
-
|| typeof detail.contentSha256 !== 'string' || !/^[0-9a-f]{64}$/u.test(detail.contentSha256)) {
|
|
38
|
-
throw new Error('Runner version detail is invalid');
|
|
39
|
-
}
|
|
40
|
-
contentSha256 = detail.contentSha256;
|
|
41
|
-
}
|
|
42
|
-
const response = await authedFetch(`/api/cli/runner-versions/${encodeURIComponent(runnerVersionId)}/download`);
|
|
43
|
-
if (!response.ok)
|
|
44
|
-
throw new Error(`Failed to download runner version: ${await response.text()}`);
|
|
45
|
-
const tempDir = mkdtempSync(join(tmpdir(), 'orizu-runner-version-'));
|
|
46
|
-
const cleanup = () => rmSync(tempDir, { recursive: true, force: true });
|
|
23
|
+
// ORI-2429: the download runs on Effect (runner-folders.ts); this adapter
|
|
24
|
+
// keeps the { runnerDir, cleanup } shape for the existing Promise callers.
|
|
25
|
+
const { downloadedRunner, openScope, RemovalLedger } = await import('./runner-folders.js');
|
|
26
|
+
const { Effect, Scope } = await import('effect');
|
|
27
|
+
const ledger = new RemovalLedger();
|
|
28
|
+
const { scope, close } = openScope();
|
|
47
29
|
try {
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
30
|
+
const { runnerDir } = await Effect.runPromise(downloadedRunner(runnerVersionId, ledger, verifyRegisteredDigest).pipe(Effect.provideService(Scope.Scope, scope)));
|
|
31
|
+
return {
|
|
32
|
+
runnerDir,
|
|
33
|
+
cleanup: () => {
|
|
34
|
+
close();
|
|
35
|
+
const [failure] = ledger.errors();
|
|
36
|
+
if (failure !== undefined)
|
|
37
|
+
throw failure;
|
|
38
|
+
},
|
|
39
|
+
};
|
|
57
40
|
}
|
|
58
41
|
catch (error) {
|
|
59
|
-
|
|
42
|
+
// downloadedRunner already removed this runner's folder on its own failure.
|
|
43
|
+
close();
|
|
44
|
+
throw error;
|
|
60
45
|
}
|
|
61
46
|
}
|
|
62
|
-
export function cleanupMaterializedRunners(cleanups) {
|
|
63
|
-
cleanupAll(cleanups, 'Failed to clean materialized runner directories');
|
|
64
|
-
}
|
|
@@ -23,12 +23,22 @@ export interface SkilledProposerChildOutput {
|
|
|
23
23
|
stdout: (chunk: string) => void;
|
|
24
24
|
stderr: (chunk: string) => void;
|
|
25
25
|
}
|
|
26
|
+
export interface SkilledProposerStopOptions {
|
|
27
|
+
/**
|
|
28
|
+
* Runs once the optimizer has exited after a stop signal, just before the
|
|
29
|
+
* CLI ends by that signal (ORI-2429: the caller removes its runner copies).
|
|
30
|
+
* It gets whatever is left of CHILD_STOP_WAIT_MS since the signal; a hook
|
|
31
|
+
* still running then is abandoned so the stop cannot hang. A synchronous
|
|
32
|
+
* hook always runs to the end, even with no time left.
|
|
33
|
+
*/
|
|
34
|
+
beforeSignalExit?: () => Promise<void> | void;
|
|
35
|
+
}
|
|
26
36
|
/**
|
|
27
37
|
* Run the optimizer, passing a stop signal on to it. On a stop signal the CLI
|
|
28
38
|
* waits until the optimizer exits (capped at CHILD_STOP_WAIT_MS, then kills
|
|
29
39
|
* it) and then ends by that same signal.
|
|
30
40
|
*/
|
|
31
|
-
export declare function spawnSkilledProposerChild(command: string, args: string[], engine: GepaEngine, environment: NodeJS.ProcessEnv, onSpawn?: () => Promise<void>, output?: SkilledProposerChildOutput): Promise<{
|
|
41
|
+
export declare function spawnSkilledProposerChild(command: string, args: string[], engine: GepaEngine, environment: NodeJS.ProcessEnv, onSpawn?: () => Promise<void>, output?: SkilledProposerChildOutput, stopOptions?: SkilledProposerStopOptions): Promise<{
|
|
32
42
|
status: number | null;
|
|
33
43
|
error?: Error;
|
|
34
44
|
}>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"skilled-proposer-launch.d.ts","sourceRoot":"","sources":["../src/skilled-proposer-launch.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,2BAA2B,CAAA;AAO3D,UAAU,yBAAyB;IACjC,IAAI,EAAE,MAAM,CAAA;IACZ,UAAU,EAAE,MAAM,CAAA;IAClB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,iBAAiB,EAAE,MAAM,EAAE,CAAA;IAC3B,QAAQ,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;IAClD,oBAAoB,EAAE,OAAO,CAAA;IAC7B,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,aAAa,CAAC,EAAE,OAAO,GAAG,IAAI,CAAA;IAC9B,eAAe,CAAC,EAAE,MAAM,EAAE,GAAG,IAAI,CAAA;CAClC;AACD,MAAM,WAAW,qBAAqB;IACpC,MAAM,EAAE,MAAM,CAAA;IACd,WAAW,EAAE,MAAM,CAAC,UAAU,CAAA;IAC9B,MAAM,CAAC,EAAE,yBAAyB,CAAA;CACnC;AAED,MAAM,WAAW,0BAA0B;IACzC,MAAM,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;IAC/B,MAAM,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;CAChC;AAsBD;;;;GAIG;AACH,wBAAsB,yBAAyB,CAC7C,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,MAAM,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,CAAC,UAAU,EACnF,OAAO,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,EAC7B,MAAM,CAAC,EAAE,0BAA0B;
|
|
1
|
+
{"version":3,"file":"skilled-proposer-launch.d.ts","sourceRoot":"","sources":["../src/skilled-proposer-launch.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,2BAA2B,CAAA;AAO3D,UAAU,yBAAyB;IACjC,IAAI,EAAE,MAAM,CAAA;IACZ,UAAU,EAAE,MAAM,CAAA;IAClB,WAAW,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,iBAAiB,EAAE,MAAM,EAAE,CAAA;IAC3B,QAAQ,EAAE,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;IAClD,oBAAoB,EAAE,OAAO,CAAA;IAC7B,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,aAAa,CAAC,EAAE,OAAO,GAAG,IAAI,CAAA;IAC9B,eAAe,CAAC,EAAE,MAAM,EAAE,GAAG,IAAI,CAAA;CAClC;AACD,MAAM,WAAW,qBAAqB;IACpC,MAAM,EAAE,MAAM,CAAA;IACd,WAAW,EAAE,MAAM,CAAC,UAAU,CAAA;IAC9B,MAAM,CAAC,EAAE,yBAAyB,CAAA;CACnC;AAED,MAAM,WAAW,0BAA0B;IACzC,MAAM,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;IAC/B,MAAM,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;CAChC;AAsBD,MAAM,WAAW,0BAA0B;IACzC;;;;;;OAMG;IACH,gBAAgB,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAA;CAC9C;AAcD;;;;GAIG;AACH,wBAAsB,yBAAyB,CAC7C,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,MAAM,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,CAAC,UAAU,EACnF,OAAO,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,EAC7B,MAAM,CAAC,EAAE,0BAA0B,EACnC,WAAW,GAAE,0BAA+B;YAoDpB,MAAM,GAAG,IAAI;YAAU,KAAK;GAsDrD;AAwCD,UAAU,aAAa;IAAG,KAAK,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IAAC,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAA;CAAE;AAQ9F,wBAAgB,0BAA0B,CAAC,EAAE,EAAE,aAAa,EAAE,MAAM,UAAQ,GAAG,MAAM,CAepF;AAED,wBAAgB,4BAA4B,CAC1C,MAAM,EAAE,MAAM,EACd,MAAM,EAAE,UAAU,EAClB,WAAW,EAAE,MAAM,CAAC,UAAU,GAC7B,qBAAqB,CAyBvB"}
|
|
@@ -27,16 +27,30 @@ const STOP_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGQUIT'];
|
|
|
27
27
|
// The optimizer marks its run stopped before exiting (at most 10 s for the
|
|
28
28
|
// status write); past this it is killed so nothing is left running (ORI-2402).
|
|
29
29
|
const CHILD_STOP_WAIT_MS = 15_000;
|
|
30
|
+
// Wait for `hook` at most until `deadline`; its failure never blocks the exit.
|
|
31
|
+
async function runBeforeSignalExit(hook, deadline) {
|
|
32
|
+
if (!hook)
|
|
33
|
+
return;
|
|
34
|
+
let timer;
|
|
35
|
+
const expired = new Promise(resolve => { timer = setTimeout(resolve, Math.max(0, deadline - Date.now())); });
|
|
36
|
+
try {
|
|
37
|
+
await Promise.race([Promise.resolve().then(hook).catch(() => { }), expired]);
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
clearTimeout(timer);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
30
43
|
/**
|
|
31
44
|
* Run the optimizer, passing a stop signal on to it. On a stop signal the CLI
|
|
32
45
|
* waits until the optimizer exits (capped at CHILD_STOP_WAIT_MS, then kills
|
|
33
46
|
* it) and then ends by that same signal.
|
|
34
47
|
*/
|
|
35
|
-
export async function spawnSkilledProposerChild(command, args, engine, environment, onSpawn, output) {
|
|
48
|
+
export async function spawnSkilledProposerChild(command, args, engine, environment, onSpawn, output, stopOptions = {}) {
|
|
36
49
|
const childEnvironment = { ...environment };
|
|
37
50
|
let child;
|
|
38
51
|
let cleanup = () => { };
|
|
39
52
|
let stopSignal;
|
|
53
|
+
let stopDeadline = 0;
|
|
40
54
|
// On the terminal path the optimizer leads its own process group, so a stop
|
|
41
55
|
// also reaches its runners, which may run in worker threads it can't stop.
|
|
42
56
|
let childGroup;
|
|
@@ -56,11 +70,18 @@ export async function spawnSkilledProposerChild(command, args, engine, environme
|
|
|
56
70
|
let giveUp;
|
|
57
71
|
const removeHandlers = () => { for (const entry of handlers)
|
|
58
72
|
process.off(entry.signal, entry.handle); };
|
|
59
|
-
|
|
73
|
+
let ending = false;
|
|
74
|
+
const endBySignal = async (signal) => {
|
|
75
|
+
if (ending)
|
|
76
|
+
return new Promise(() => { });
|
|
77
|
+
ending = true;
|
|
60
78
|
if (giveUp)
|
|
61
79
|
clearTimeout(giveUp);
|
|
62
|
-
removeHandlers();
|
|
63
80
|
cleanup();
|
|
81
|
+
// Our handlers stay installed during the caller's cleanup, so a second
|
|
82
|
+
// Ctrl-C then is absorbed instead of ending the CLI by default mid-cleanup.
|
|
83
|
+
await runBeforeSignalExit(stopOptions.beforeSignalExit, stopDeadline);
|
|
84
|
+
removeHandlers();
|
|
64
85
|
// With our handlers gone, the signal's default action ends the process.
|
|
65
86
|
process.kill(process.pid, signal);
|
|
66
87
|
return new Promise(() => { });
|
|
@@ -68,6 +89,8 @@ export async function spawnSkilledProposerChild(command, args, engine, environme
|
|
|
68
89
|
const handlers = STOP_SIGNALS.map(signal => ({
|
|
69
90
|
signal,
|
|
70
91
|
handle: () => {
|
|
92
|
+
if (!stopDeadline)
|
|
93
|
+
stopDeadline = Date.now() + CHILD_STOP_WAIT_MS;
|
|
71
94
|
if (!child) {
|
|
72
95
|
void endBySignal(signal);
|
|
73
96
|
return;
|
|
@@ -87,9 +110,17 @@ export async function spawnSkilledProposerChild(command, args, engine, environme
|
|
|
87
110
|
try {
|
|
88
111
|
cleanup = materializeConfigPayload(engine, childEnvironment) ?? cleanup;
|
|
89
112
|
await new Promise(resolve => setImmediate(resolve));
|
|
113
|
+
// A stop before the optimizer starts ends the CLI (endBySignal); never start it then.
|
|
114
|
+
if (stopDeadline)
|
|
115
|
+
return await new Promise(() => { });
|
|
90
116
|
let result;
|
|
91
117
|
if (output) {
|
|
92
|
-
result = await runPipedChild(command, args, childEnvironment, async () => { await onSpawn?.(); }, output, spawnedChild => {
|
|
118
|
+
result = await runPipedChild(command, args, childEnvironment, async () => { await onSpawn?.(); }, output, spawnedChild => {
|
|
119
|
+
child = spawnedChild;
|
|
120
|
+
// Stopped while the output sinks were prepared: the CLI is ending.
|
|
121
|
+
if (stopDeadline && !stopSignal)
|
|
122
|
+
spawnedChild.kill('SIGKILL');
|
|
123
|
+
});
|
|
93
124
|
}
|
|
94
125
|
else {
|
|
95
126
|
// stdin is not passed on: a process outside the terminal's foreground
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, rmSync } from 'node:fs'
|
|
2
|
-
import {
|
|
2
|
+
import { spawn } from 'node:child_process'
|
|
3
3
|
import { dirname, resolve } from 'node:path'
|
|
4
4
|
import { fileURLToPath } from 'node:url'
|
|
5
5
|
|
|
@@ -20,11 +20,49 @@ if (!isOrizuSourceCheckout(resolve(repoRoot, 'package.json'))) {
|
|
|
20
20
|
process.exit(0)
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
23
|
+
// ORI-2425: in a source checkout Effect owns the build's lifetime. The build
|
|
24
|
+
// runs in its own process group, so a stop of the pack (SIGTERM from a
|
|
25
|
+
// cancelled job or a test timeout, or Ctrl-C) ends the compiler too, and the
|
|
26
|
+
// script then ends by that signal. The published branch above never loads it.
|
|
27
|
+
const { Effect } = await import('effect')
|
|
28
|
+
const isWindows = process.platform === 'win32'
|
|
29
|
+
const npm = isWindows ? 'npm.cmd' : 'npm'
|
|
30
|
+
const buildFailed = message => new Error(`Unable to build CLI dist before packing: ${message}`)
|
|
31
|
+
const build = Effect.callback(resume => {
|
|
32
|
+
// stdin is not passed on: a process group outside the terminal's foreground would be stopped reading it.
|
|
33
|
+
// Windows has no process groups (and detached opens a console), so there it stays attached.
|
|
34
|
+
const child = spawn(npm, ['run', 'build'], { cwd: cliRoot, stdio: ['ignore', 'inherit', 'inherit'], detached: !isWindows })
|
|
35
|
+
child.once('error', error => resume(Effect.fail(buildFailed(error.message))))
|
|
36
|
+
child.once('close', status => resume(status === 0 ? Effect.void : Effect.fail(buildFailed('npm run build failed'))))
|
|
37
|
+
// Interrupted: end the whole build group; the next pack rebuilds dist anyway.
|
|
38
|
+
return Effect.sync(() => {
|
|
39
|
+
try { if (isWindows) child.kill(); else process.kill(-child.pid, 'SIGKILL') } catch { /* already gone */ }
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const STOP_SIGNALS = ['SIGINT', 'SIGTERM']
|
|
44
|
+
const controller = new AbortController()
|
|
45
|
+
let stopSignal
|
|
46
|
+
const handlers = STOP_SIGNALS.map(signal => [signal, () => { stopSignal ??= signal; controller.abort() }])
|
|
47
|
+
for (const [signal, handler] of handlers) process.on(signal, handler)
|
|
48
|
+
// npm stops a pack by signalling only the shell running prepack; this script
|
|
49
|
+
// is then re-parented and gets no signal. A changed parent means the pack is
|
|
50
|
+
// gone, so stop the same way.
|
|
51
|
+
const parentAtStart = process.ppid
|
|
52
|
+
const parentWatch = setInterval(() => {
|
|
53
|
+
if (process.ppid !== parentAtStart) { stopSignal ??= 'SIGTERM'; controller.abort() }
|
|
54
|
+
}, 200)
|
|
55
|
+
let failure
|
|
56
|
+
try {
|
|
57
|
+
// Delete old compiler output first: npm pack must never retain a module whose
|
|
58
|
+
// TypeScript source was renamed or removed since the previous build.
|
|
59
|
+
rmSync(resolve(cliRoot, 'dist'), { recursive: true, force: true })
|
|
60
|
+
await Effect.runPromise(build, { signal: controller.signal })
|
|
61
|
+
} catch (error) {
|
|
62
|
+
failure = error
|
|
63
|
+
} finally {
|
|
64
|
+
clearInterval(parentWatch)
|
|
65
|
+
for (const [signal, handler] of handlers) process.off(signal, handler)
|
|
30
66
|
}
|
|
67
|
+
if (stopSignal) process.kill(process.pid, stopSignal)
|
|
68
|
+
else if (failure) throw failure
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { cpSync, existsSync, mkdirSync, rmSync } from 'node:fs'
|
|
1
|
+
import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, renameSync, rmSync } from 'node:fs'
|
|
2
2
|
import { dirname, isAbsolute, relative, resolve } from 'node:path'
|
|
3
3
|
import { fileURLToPath } from 'node:url'
|
|
4
4
|
|
|
@@ -32,9 +32,20 @@ function shouldCopyPath(sourcePath) {
|
|
|
32
32
|
) && !rel.endsWith('.pyc') && !rel.endsWith('.pyo')
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
const STAGING_PREFIX = '.orizu-staging-'
|
|
36
|
+
// A pack killed outright leaves its staging folder next to the vendored skill.
|
|
37
|
+
function removeStaleStaging() {
|
|
38
|
+
const vendorParent = dirname(vendorRoot)
|
|
39
|
+
if (!existsSync(vendorParent)) return
|
|
40
|
+
for (const name of readdirSync(vendorParent)) {
|
|
41
|
+
if (name.startsWith(STAGING_PREFIX)) rmSync(resolve(vendorParent, name), { recursive: true, force: true })
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
35
45
|
if (shouldClean) {
|
|
36
46
|
if (hasSourceSkill) {
|
|
37
47
|
rmSync(vendorRoot, { recursive: true, force: true })
|
|
48
|
+
removeStaleStaging()
|
|
38
49
|
}
|
|
39
50
|
process.exit(0)
|
|
40
51
|
}
|
|
@@ -46,9 +57,43 @@ if (!hasSourceSkill) {
|
|
|
46
57
|
throw new Error(`Unable to vendor orizu skill: source skill not found at ${sourceRoot}`)
|
|
47
58
|
}
|
|
48
59
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
60
|
+
// ORI-2425: in a source checkout Effect owns the vendored copy's lifetime. It
|
|
61
|
+
// is copied into a staging folder that the scope removes if the copy fails or
|
|
62
|
+
// the pack is stopped, then swapped in whole, so the CLI (which prefers this
|
|
63
|
+
// copy over skills/orizu) never finds a half-vendored skill.
|
|
64
|
+
const { Effect } = await import('effect')
|
|
65
|
+
const vendorParent = dirname(vendorRoot)
|
|
66
|
+
const vendor = Effect.scoped(Effect.gen(function* () {
|
|
67
|
+
mkdirSync(vendorParent, { recursive: true })
|
|
68
|
+
removeStaleStaging()
|
|
69
|
+
const staging = yield* Effect.acquireRelease(
|
|
70
|
+
Effect.sync(() => {
|
|
71
|
+
const staging = mkdtempSync(resolve(vendorParent, STAGING_PREFIX))
|
|
72
|
+
// mkdtemp makes 0700 folders; keep the permissions a plain mkdir gave before.
|
|
73
|
+
chmodSync(staging, 0o755)
|
|
74
|
+
return staging
|
|
75
|
+
}),
|
|
76
|
+
staging => Effect.sync(() => rmSync(staging, { recursive: true, force: true })),
|
|
77
|
+
)
|
|
78
|
+
cpSync(sourceRoot, staging, { recursive: true, filter: shouldCopyPath })
|
|
79
|
+
yield* Effect.uninterruptible(Effect.sync(() => {
|
|
80
|
+
rmSync(vendorRoot, { recursive: true, force: true })
|
|
81
|
+
renameSync(staging, vendorRoot)
|
|
82
|
+
}))
|
|
83
|
+
}))
|
|
84
|
+
|
|
85
|
+
const STOP_SIGNALS = ['SIGINT', 'SIGTERM']
|
|
86
|
+
const controller = new AbortController()
|
|
87
|
+
let stopSignal
|
|
88
|
+
const handlers = STOP_SIGNALS.map(signal => [signal, () => { stopSignal ??= signal; controller.abort() }])
|
|
89
|
+
for (const [signal, handler] of handlers) process.on(signal, handler)
|
|
90
|
+
let failure
|
|
91
|
+
try {
|
|
92
|
+
await Effect.runPromise(vendor, { signal: controller.signal })
|
|
93
|
+
} catch (error) {
|
|
94
|
+
failure = error
|
|
95
|
+
} finally {
|
|
96
|
+
for (const [signal, handler] of handlers) process.off(signal, handler)
|
|
97
|
+
}
|
|
98
|
+
if (stopSignal) process.kill(process.pid, stopSignal)
|
|
99
|
+
else if (failure) throw failure
|
|
@@ -3,13 +3,12 @@ import { existsSync, readFileSync, rmSync } from 'node:fs'
|
|
|
3
3
|
import { createServer } from 'node:http'
|
|
4
4
|
import { tmpdir } from 'node:os'
|
|
5
5
|
import { delimiter, join } from 'node:path'
|
|
6
|
-
import { runPipedChild } from './child-output-tee.js'
|
|
7
6
|
import { getGepaPythonPathEntries } from './gepa-python-paths.js'
|
|
8
7
|
import { deliveredProviderLookup, HOSTED_PROVIDER_FAILURE, INJECTED_ENV_VARS_ENV, PROVIDER_REGISTRY_EXTRA_ENV } from './hosted-environment.js'
|
|
9
8
|
import { credentialEnvFor, parseModelIdentity } from './provider-registry.js'
|
|
10
9
|
import type { ProviderLookup } from './provider-lookup.js'
|
|
11
|
-
import { materializeRunnerVersion, cleanupMaterializedRunners } from './runner-version-materialization.js'
|
|
12
10
|
import { prepareSkilledProposerLaunch, spawnSkilledProposerChild } from './skilled-proposer-launch.js'
|
|
11
|
+
import type { SkilledProposerStopOptions } from './skilled-proposer-launch.js'
|
|
13
12
|
import { hostedProviderFromModel } from './hosted-provider-settings.js'
|
|
14
13
|
import { validNormalizedSkilledProposerConfig } from './skilled-proposer-wire.js'
|
|
15
14
|
import { REDACTION_PLACEHOLDER, redactSecrets } from './secret-redaction.js'
|
|
@@ -358,7 +357,8 @@ async function startTokenBroker(
|
|
|
358
357
|
}
|
|
359
358
|
return {
|
|
360
359
|
url: `http://127.0.0.1:${address.port}/${nonce}`,
|
|
361
|
-
|
|
360
|
+
// Also drop open keep-alive connections, so no refresh succeeds after stop.
|
|
361
|
+
stop: () => { server.close(); server.closeAllConnections() },
|
|
362
362
|
token: () => activeToken,
|
|
363
363
|
secrets: () => [...tokens],
|
|
364
364
|
}
|
|
@@ -367,30 +367,23 @@ async function spawnOfficial(
|
|
|
367
367
|
python: string,
|
|
368
368
|
environment: NodeJS.ProcessEnv,
|
|
369
369
|
ready: () => Promise<void>,
|
|
370
|
-
output: ProcessOutputCapture
|
|
370
|
+
output: ProcessOutputCapture,
|
|
371
|
+
stopOptions: SkilledProposerStopOptions,
|
|
371
372
|
): Promise<{ status: number | null; error?: Error }> {
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
'official',
|
|
377
|
-
environment,
|
|
378
|
-
ready,
|
|
379
|
-
{
|
|
380
|
-
stdout: chunk => output.stdout.append(chunk),
|
|
381
|
-
stderr: chunk => output.stderr.append(chunk),
|
|
382
|
-
}
|
|
383
|
-
)
|
|
384
|
-
}
|
|
385
|
-
return runPipedChild(
|
|
373
|
+
// ORI-2429: both proposers start through the launcher (ORI-2402), so a stop
|
|
374
|
+
// signal reaches the optimizer, which then ends instead of outliving the
|
|
375
|
+
// CLI, and the CLI cleans up before it ends by that signal.
|
|
376
|
+
return spawnSkilledProposerChild(
|
|
386
377
|
python,
|
|
387
378
|
['-m', 'orizu_gepa_connector'],
|
|
379
|
+
'official',
|
|
388
380
|
environment,
|
|
389
381
|
ready,
|
|
390
382
|
{
|
|
391
383
|
stdout: chunk => output.stdout.append(chunk),
|
|
392
384
|
stderr: chunk => output.stderr.append(chunk),
|
|
393
|
-
}
|
|
385
|
+
},
|
|
386
|
+
stopOptions,
|
|
394
387
|
)
|
|
395
388
|
}
|
|
396
389
|
async function ensureFailedRun(
|
|
@@ -430,7 +423,14 @@ export async function hostedOptimizationCommand(io: HostedOptimizationIo): Promi
|
|
|
430
423
|
}
|
|
431
424
|
delete process.env.ORIZU_BOOT_SECRET
|
|
432
425
|
let reason = 'hosted_optimization_mint_refused'
|
|
433
|
-
|
|
426
|
+
// ORI-2429: one Effect scope owns the runner copies, the result file and the
|
|
427
|
+
// token broker, so a stop signal removes them too (see runner-folders.ts).
|
|
428
|
+
const { Effect, Scope } = await import('effect')
|
|
429
|
+
const { downloadedRunner, removeOnClose, RemovalLedger, stopClosesScope } = await import('./runner-folders.js')
|
|
430
|
+
const ledger = new RemovalLedger()
|
|
431
|
+
const { scope, close, stop, listen, removeHandlers } = stopClosesScope()
|
|
432
|
+
const inScope = <A>(effect: import('effect').Effect.Effect<A, unknown, import('effect').Scope.Scope>) =>
|
|
433
|
+
Effect.runPromise(effect.pipe(Effect.provideService(Scope.Scope, scope)))
|
|
434
434
|
let broker: TokenBroker | undefined
|
|
435
435
|
const resultFile = join(tmpdir(), `orizu-hosted-optimization-result-${boot.runId}-${randomUUID()}.json`)
|
|
436
436
|
const diagnosticSecrets = () => injectedSecrets(boot, broker)
|
|
@@ -438,6 +438,7 @@ export async function hostedOptimizationCommand(io: HostedOptimizationIo): Promi
|
|
|
438
438
|
const bootOutput = new BoundedTextCapture(diagnosticSecrets)
|
|
439
439
|
let failureDiagnostics: FailureDiagnostics | undefined
|
|
440
440
|
try {
|
|
441
|
+
listen()
|
|
441
442
|
const first = await mint(boot)
|
|
442
443
|
if (first.response.status === 422) reason = 'hosted_optimization_spec_missing'
|
|
443
444
|
if (!first.response.ok) {
|
|
@@ -462,18 +463,28 @@ export async function hostedOptimizationCommand(io: HostedOptimizationIo): Promi
|
|
|
462
463
|
process.env.ORIZU_BASE_URL = boot.baseUrl.origin
|
|
463
464
|
process.env.ORIZU_TOKEN = minted.token
|
|
464
465
|
reason = 'hosted_optimization_materialization_failed'
|
|
465
|
-
const candidate = await
|
|
466
|
-
const scorer = await
|
|
466
|
+
const candidate = await inScope(downloadedRunner(String(minted.jobSpec.runnerVersionId), ledger))
|
|
467
|
+
const scorer = await inScope(downloadedRunner(String(minted.jobSpec.scorerRunnerVersionId), ledger))
|
|
468
|
+
await inScope(removeOnClose(ledger, 'other', () => rmSync(resultFile, { force: true })))
|
|
467
469
|
broker = await startTokenBroker(boot, minted)
|
|
470
|
+
const startedBroker = broker
|
|
471
|
+
await inScope(removeOnClose(ledger, 'other', () => startedBroker.stop()))
|
|
468
472
|
const environment = childEnvironment(boot, minted, candidate.runnerDir, scorer.runnerDir, broker.url, resultFile)
|
|
469
473
|
const launch = prepareSkilledProposerLaunch(process.env.PYTHON || 'python3', 'official', environment)
|
|
470
474
|
reason = 'hosted_optimization_process_failed'
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
475
|
+
stop.launcherOwnsStop()
|
|
476
|
+
let result: Awaited<ReturnType<typeof spawnOfficial>>
|
|
477
|
+
try {
|
|
478
|
+
result = await spawnOfficial(
|
|
479
|
+
launch.python,
|
|
480
|
+
launch.environment,
|
|
481
|
+
() => postBoot(boot, 'ready', { status: 'ready' }),
|
|
482
|
+
processOutput,
|
|
483
|
+
{ beforeSignalExit: stop.beforeSignalExit },
|
|
484
|
+
)
|
|
485
|
+
} finally {
|
|
486
|
+
stop.launcherDone()
|
|
487
|
+
}
|
|
477
488
|
if (result.error || result.status !== 0) {
|
|
478
489
|
const processFailure = result.error?.message || `optimizer exited with status ${result.status}`
|
|
479
490
|
const secrets = injectedSecrets(boot, broker)
|
|
@@ -506,8 +517,15 @@ export async function hostedOptimizationCommand(io: HostedOptimizationIo): Promi
|
|
|
506
517
|
await io.printErr?.(reason)
|
|
507
518
|
return 1
|
|
508
519
|
} finally {
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
520
|
+
removeHandlers()
|
|
521
|
+
// Closing stops the broker, deletes the result file and removes the copies.
|
|
522
|
+
close()
|
|
523
|
+
// As before: a broker or result-file failure is thrown first, then runner removals.
|
|
524
|
+
const otherFailure = ledger.failures.find(failure => failure.group === 'other')
|
|
525
|
+
if (otherFailure) throw otherFailure.error
|
|
526
|
+
const runnerFailures = ledger.failures.filter(failure => failure.group === 'downloads').map(failure => failure.error)
|
|
527
|
+
if (runnerFailures.length > 0) {
|
|
528
|
+
throw new AggregateError(runnerFailures, 'Failed to clean materialized runner directories')
|
|
529
|
+
}
|
|
512
530
|
}
|
|
513
531
|
}
|