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.
@@ -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 unzip(zipPath, runnerDir) {
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
- let contentSha256;
31
- if (verifyRegisteredDigest) {
32
- const detailResponse = await authedFetch(`/api/cli/runner-versions/${encodeURIComponent(runnerVersionId)}`);
33
- if (!detailResponse.ok)
34
- throw new Error(`Failed to resolve runner version: ${await detailResponse.text()}`);
35
- const detail = await detailResponse.json();
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 zipBytes = new Uint8Array(await response.arrayBuffer());
49
- if (zipBytes.byteLength > ARTIFACT_MAX_BYTES)
50
- throw new Error(`Runner artifact exceeds ${ARTIFACT_MAX_BYTES} bytes`);
51
- if (contentSha256 && createHash('sha256').update(zipBytes).digest('hex') !== contentSha256)
52
- throw new Error('Runner artifact digest mismatch');
53
- const zipPath = join(tempDir, 'runner.zip'), runnerDir = join(tempDir, 'runner');
54
- writeFileSync(zipPath, zipBytes);
55
- unzip(zipPath, runnerDir);
56
- return { runnerDir, cleanup };
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
- throwWithCleanup(error, [cleanup], `Runner ${runnerVersionId} materialization failed and cleanup also failed`);
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;YA0CX,MAAM,GAAG,IAAI;YAAU,KAAK;GAkDrD;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"}
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
- const endBySignal = (signal) => {
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 => { child = 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,6 +1,6 @@
1
1
  {
2
2
  "name": "orizu",
3
- "version": "0.5.48",
3
+ "version": "0.5.49",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,5 +1,5 @@
1
1
  import { existsSync, rmSync } from 'node:fs'
2
- import { spawnSync } from 'node:child_process'
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
- const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'
24
- // Delete old compiler output first: npm pack must never retain a module whose
25
- // TypeScript source was renamed or removed since the previous build.
26
- rmSync(resolve(cliRoot, 'dist'), { recursive: true, force: true })
27
- const result = spawnSync(npm, ['run', 'build'], { cwd: cliRoot, stdio: 'inherit' })
28
- if (result.status !== 0) {
29
- throw new Error(`Unable to build CLI dist before packing: ${result.error?.message || 'npm run build failed'}`)
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
- rmSync(vendorRoot, { recursive: true, force: true })
50
- mkdirSync(vendorRoot, { recursive: true })
51
- cpSync(sourceRoot, vendorRoot, {
52
- recursive: true,
53
- filter: shouldCopyPath,
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
- stop: () => { server.close() },
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
- if (environment.ORIZU_CANDIDATE_PROPOSER === 'skilled-proposer') {
373
- return spawnSkilledProposerChild(
374
- python,
375
- ['-m', 'orizu_gepa_connector'],
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
- const cleanups: Array<() => void> = []
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 materializeRunnerVersion(String(minted.jobSpec.runnerVersionId)); cleanups.push(candidate.cleanup)
466
- const scorer = await materializeRunnerVersion(String(minted.jobSpec.scorerRunnerVersionId)); cleanups.push(scorer.cleanup)
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
- const result = await spawnOfficial(
472
- launch.python,
473
- launch.environment,
474
- () => postBoot(boot, 'ready', { status: 'ready' }),
475
- processOutput
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
- broker?.stop()
510
- rmSync(resultFile, { force: true })
511
- cleanupMaterializedRunners(cleanups)
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
  }