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/src/index.ts CHANGED
@@ -100,7 +100,6 @@ import {
100
100
  getTaskReport,
101
101
  updateTaskStatus,
102
102
  } from './task-commands.js'
103
- import { cleanupAll, throwWithCleanup } from './cleanup.js'
104
103
  import {
105
104
  extractFlagValue,
106
105
  verifyGepaRunnerDirsFromArgs,
@@ -177,7 +176,6 @@ import { inspectExistingWorkspaceTeam, getWorkspaceRoot } from './workspace.js'
177
176
  import { writeLastErrorRecord, extractLastErrorCode } from './last-error-record.js'
178
177
  import type { CliLengthStats, CliLengthMeasurementUnavailableReason } from './prompt-length-wire.js'
179
178
  import type { RunnerInstructionSet } from './runner-instruction-set-contract.js'
180
- import type { GepaVerifiedArgs } from './runner-dir-verify.js'
181
179
  import type { ReportCommentsCliContext } from './report-comments-cli.js'
182
180
 
183
181
 
@@ -1293,101 +1291,94 @@ const GEPA_RUNNER_MATERIALIZATION_PAIRS = [
1293
1291
  { versionFlag: '--scorer-runner-version-id', dirFlag: '--scorer-runner-dir' },
1294
1292
  ] as const
1295
1293
 
1296
- async function materializeMissingGepaRunnerDirs(args: string[]): Promise<{
1297
- args: string[]
1298
- cleanup: () => void
1299
- }> {
1300
- const rewritten = [...args]
1301
- const cleanups: Array<() => void> = []
1302
- const cleanup = () => cleanupAll(
1303
- cleanups,
1304
- 'Failed to clean materialized runner directories'
1305
- )
1306
-
1307
- try {
1308
- for (const pair of GEPA_RUNNER_MATERIALIZATION_PAIRS) {
1309
- const runnerVersionId = extractFlagValue(rewritten, pair.versionFlag)
1310
- const runnerDir = extractFlagValue(rewritten, pair.dirFlag)
1311
- if (!runnerVersionId || runnerDir) continue
1312
-
1313
- const materialized = await materializeRunnerVersion(runnerVersionId)
1314
- cleanups.push(materialized.cleanup)
1315
- rewritten.push(pair.dirFlag, materialized.runnerDir)
1316
- }
1317
- } catch (error) {
1318
- throwWithCleanup(
1319
- error,
1320
- cleanups,
1321
- 'Runner materialization failed and cleanup also failed'
1322
- )
1323
- }
1324
-
1325
- return { args: rewritten, cleanup }
1326
- }
1327
-
1328
1294
  async function runGepaOptimization() {
1329
1295
  const project = getArg('--project') || await resolveProjectSlug(null)
1330
1296
  const baseUrl = getBaseUrl()
1331
1297
  const runGepa = getGepaPythonCommand(cliArgs.slice(2), process.env)
1332
1298
  const python = runGepa.python
1333
- let forwardedArgs = runGepa.args
1334
- // ALI-1159 (ADR-007): GEPA executes ad-hoc local runner dirs while
1335
- // attributing records to registered runner version ids — verify the bytes
1336
- // ARE those versions before the optimizer spawns. Scans the forwarded argv
1337
- // itself (both argparse flag forms), fail-closed on half-populated pairs,
1338
- // and REWRITES the dir flags to verified snapshots (the exact hashed bytes)
1339
- // so a post-hash mutation of the original dirs cannot change what runs.
1340
- const materialized = await materializeMissingGepaRunnerDirs(forwardedArgs)
1341
- let verified: GepaVerifiedArgs | undefined
1299
+ // ORI-2429: one Effect scope owns every runner copy this run writes to the
1300
+ // temp folder; it removes them on success, failure and stop signals.
1301
+ const { Effect } = await import('effect')
1302
+ const { downloadedRunner, heldUntilClose, RemovalLedger, runStoppable } = await import('./runner-folders.js')
1303
+ const ledger = new RemovalLedger()
1304
+ // Which step failed decides the message when a removal also failed, as before.
1305
+ let phase: 'materialize' | 'launch' = 'materialize'
1342
1306
  let selectedEngine = 'official'
1307
+ let failure: { error: unknown } | undefined
1343
1308
  try {
1344
- verified = await verifyGepaRunnerDirsFromArgs(materialized.args)
1345
- forwardedArgs = verified.args
1346
- // Uniform resolution (ALI-1090): honors ORIZU_TOKEN / ORIZU_TOKEN_FILE before
1347
- // credentials.json so hosted sandboxes can run optimizations. Read at spawn
1348
- // time — AFTER the verification awaits above, so a hosted token-file
1349
- // rotation crossing the hash + lookup round-trips cannot hand the child a
1350
- // stale bearer.
1351
- const token = resolveAuthTokenForBaseUrl(baseUrl)
1352
- if (!forwardedArgs.includes('--project')) {
1353
- forwardedArgs = ['--project', project, ...forwardedArgs]
1354
- }
1355
- const dispatch = applyHostedInstructionSetProfileOverride(dispatchGepaEngine(forwardedArgs, project, {
1356
- ...process.env,
1357
- ORIZU_API_URL: baseUrl,
1358
- ORIZU_TOKEN: token,
1359
- ORIZU_PROJECT: project,
1360
- ORIZU_VERIFIED_RUNNER_DIRS: JSON.stringify(verified.verifiedDirs),
1361
- PYTHONPATH: getGepaPythonPathEntries(process.env.PYTHONPATH).join(delimiter),
1362
- PYTHONUNBUFFERED: process.env.PYTHONUNBUFFERED || '1',
1309
+ await runStoppable(stop => Effect.gen(function* () {
1310
+ // Download registered runner versions passed without a local folder; the
1311
+ // verification below then checks those bytes like any local folder.
1312
+ const materializedArgs = [...runGepa.args]
1313
+ for (const pair of GEPA_RUNNER_MATERIALIZATION_PAIRS) {
1314
+ const runnerVersionId = extractFlagValue(materializedArgs, pair.versionFlag)
1315
+ const runnerDir = extractFlagValue(materializedArgs, pair.dirFlag)
1316
+ if (!runnerVersionId || runnerDir) continue
1317
+ const materialized = yield* downloadedRunner(runnerVersionId, ledger, false)
1318
+ materializedArgs.push(pair.dirFlag, materialized.runnerDir)
1319
+ }
1320
+ phase = 'launch'
1321
+ // ALI-1159 (ADR-007): GEPA executes ad-hoc local runner dirs while
1322
+ // attributing records to registered runner version ids — verify the bytes
1323
+ // ARE those versions before the optimizer spawns. Scans the forwarded argv
1324
+ // itself (both argparse flag forms), fail-closed on half-populated pairs,
1325
+ // and REWRITES the dir flags to verified snapshots (the exact hashed bytes)
1326
+ // so a post-hash mutation of the original dirs cannot change what runs.
1327
+ const verified = yield* heldUntilClose(ledger, 'snapshots', () => verifyGepaRunnerDirsFromArgs(materializedArgs))
1328
+ let forwardedArgs = verified.args
1329
+ // Uniform resolution (ALI-1090): honors ORIZU_TOKEN / ORIZU_TOKEN_FILE before
1330
+ // credentials.json so hosted sandboxes can run optimizations. Read at spawn
1331
+ // time — AFTER the verification awaits above, so a hosted token-file
1332
+ // rotation crossing the hash + lookup round-trips cannot hand the child a
1333
+ // stale bearer.
1334
+ const token = resolveAuthTokenForBaseUrl(baseUrl)
1335
+ if (!forwardedArgs.includes('--project')) {
1336
+ forwardedArgs = ['--project', project, ...forwardedArgs]
1337
+ }
1338
+ const dispatch = applyHostedInstructionSetProfileOverride(dispatchGepaEngine(forwardedArgs, project, {
1339
+ ...process.env,
1340
+ ORIZU_API_URL: baseUrl,
1341
+ ORIZU_TOKEN: token,
1342
+ ORIZU_PROJECT: project,
1343
+ ORIZU_VERIFIED_RUNNER_DIRS: JSON.stringify(verified.verifiedDirs),
1344
+ PYTHONPATH: getGepaPythonPathEntries(process.env.PYTHONPATH).join(delimiter),
1345
+ PYTHONUNBUFFERED: process.env.PYTHONUNBUFFERED || '1',
1346
+ }))
1347
+ const instructionSetProfileVersionId = yield* Effect.tryPromise({
1348
+ try: () => resolveGepaSeedFromEnvironment(dispatch.environment, project, 'production'),
1349
+ catch: error => error,
1350
+ })
1351
+ if (instructionSetProfileVersionId) dispatch.environment.ORIZU_INSTRUCTION_SET_PROFILE_VERSION_ID = instructionSetProfileVersionId
1352
+ selectedEngine = dispatch.engine
1353
+ const launch = prepareSkilledProposerLaunch(python, dispatch.engine, dispatch.environment)
1354
+ // While the optimizer runs, the launcher owns stop signals (ORI-2402) and
1355
+ // closes this scope just before the CLI ends by the signal. Taken in the
1356
+ // same step as the launch, so no stop can fall between the two.
1357
+ const result = yield* Effect.tryPromise({
1358
+ try: () => (stop.launcherOwnsStop(), spawnSkilledProposerChild(
1359
+ launch.python, ['-m', dispatch.module, ...dispatch.args], dispatch.engine, launch.environment,
1360
+ undefined, undefined, { beforeSignalExit: stop.beforeSignalExit },
1361
+ )),
1362
+ catch: error => error,
1363
+ }).pipe(Effect.ensuring(Effect.sync(stop.launcherDone)))
1364
+ if (result.error) {
1365
+ return yield* Effect.fail(new Error(`${selectedEngine} GEPA engine failed: ${result.error.message}`, { cause: result.error }))
1366
+ }
1367
+ if (result.status !== 0) {
1368
+ return yield* Effect.fail(new Error(`${selectedEngine} GEPA engine failed with exit code ${result.status}`))
1369
+ }
1363
1370
  }))
1364
- const instructionSetProfileVersionId = await resolveGepaSeedFromEnvironment(dispatch.environment, project, 'production')
1365
- if (instructionSetProfileVersionId) dispatch.environment.ORIZU_INSTRUCTION_SET_PROFILE_VERSION_ID = instructionSetProfileVersionId
1366
- selectedEngine = dispatch.engine
1367
- const launch = prepareSkilledProposerLaunch(python, dispatch.engine, dispatch.environment)
1368
- const result = await spawnSkilledProposerChild(
1369
- launch.python, ['-m', dispatch.module, ...dispatch.args], dispatch.engine, launch.environment,
1370
- )
1371
- if (result.error) {
1372
- throw new Error(`${selectedEngine} GEPA engine failed: ${result.error.message}`, { cause: result.error })
1373
- }
1374
- if (result.status !== 0) {
1375
- throw new Error(`${selectedEngine} GEPA engine failed with exit code ${result.status}`)
1376
- }
1377
1371
  } catch (error) {
1378
- throwWithCleanup(
1379
- error,
1380
- [
1381
- ...(verified ? [verified.cleanup] : []),
1382
- materialized.cleanup,
1383
- ],
1384
- 'GEPA launch failed and runner cleanup also failed'
1385
- )
1372
+ failure = { error }
1386
1373
  }
1387
- cleanupAll(
1388
- [verified.cleanup, materialized.cleanup],
1389
- 'Failed to clean GEPA runner directories'
1390
- )
1374
+ // The scope has closed here; a removal failure decides the message, as before.
1375
+ if (ledger.has()) {
1376
+ if (!failure) throw new AggregateError(ledger.errors(), 'Failed to clean GEPA runner directories')
1377
+ throw new AggregateError([failure.error, ...ledger.errors()], phase === 'materialize'
1378
+ ? 'Runner materialization failed and cleanup also failed'
1379
+ : 'GEPA launch failed and runner cleanup also failed')
1380
+ }
1381
+ if (failure) throw failure.error
1391
1382
  if (hasJsonFlag()) {
1392
1383
  printJson({ status: 'completed', exitCode: 0 })
1393
1384
  }
@@ -2256,6 +2247,7 @@ function isCliEntrypoint(): boolean {
2256
2247
  if (isCliEntrypoint()) {
2257
2248
  const rawArgs = process.argv.slice(2)
2258
2249
  withPassiveUpdateNotice(rawArgs, getCliVersion, printError, args =>
2250
+ // ORI-2429: the process-boundary adapter stays a Promise (ADR-038 guide); the record write is Effect-owned and never replaces this error.
2259
2251
  main(args).catch(async error => {
2260
2252
  const message = error instanceof Error ? error.message : 'Unknown error'
2261
2253
  // The reporter email is unknown on the failure path, so the record masks every address.
@@ -0,0 +1,223 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { Cause, Effect, Exit, Fiber, Scope } from 'effect'
6
+
7
+ import { ARTIFACT_MAX_BYTES } from './artifact-pull.js'
8
+ import { authedFetch } from './http.js'
9
+ import { unzipRunnerArchive } from './runner-version-materialization.js'
10
+
11
+ // ORI-2429: Effect owns the lifetime of the runner copies the CLI writes to
12
+ // the temp folder (downloaded runner versions and verified snapshots). They
13
+ // hold the customer's runner code, so the scope removes them however the run
14
+ // ends: success, failure or a stop signal. Loaded with a dynamic import, so
15
+ // commands that never copy a runner do not load Effect.
16
+
17
+ export type RemovalGroup = 'downloads' | 'snapshots' | 'other'
18
+
19
+ /** Removal failures seen while a scope closes; Effect finalizers cannot fail. */
20
+ export class RemovalLedger {
21
+ readonly failures: { group: RemovalGroup; error: unknown }[] = []
22
+ has(group?: RemovalGroup): boolean {
23
+ return this.failures.some(failure => !group || failure.group === group)
24
+ }
25
+ errors(): unknown[] {
26
+ return this.failures.map(failure => failure.error)
27
+ }
28
+ }
29
+
30
+ /** Remove with `remove` when the surrounding scope closes, recording a failure. */
31
+ export function removeOnClose(ledger: RemovalLedger, group: RemovalGroup, remove: () => void) {
32
+ return Effect.addFinalizer(() => Effect.sync(() => {
33
+ try { remove() } catch (error) { ledger.failures.push({ group, error }) }
34
+ }))
35
+ }
36
+
37
+ function request(path: string, signal: AbortSignal) {
38
+ return Effect.tryPromise({
39
+ try: requestSignal => authedFetch(path, { signal: AbortSignal.any([requestSignal, signal]) }),
40
+ catch: error => error,
41
+ })
42
+ }
43
+
44
+ function text(response: Response) {
45
+ return Effect.tryPromise({ try: () => response.text(), catch: error => error })
46
+ }
47
+
48
+ /**
49
+ * Download a registered runner version into a private temp folder that the
50
+ * caller's scope removes. A stop mid-download aborts the transfer and still
51
+ * removes the folder. Messages match the previous Promise implementation.
52
+ */
53
+ export function downloadedRunner(runnerVersionId: string, ledger: RemovalLedger, verifyRegisteredDigest = true) {
54
+ return Effect.gen(function* () {
55
+ // Aborting on close also ends a body still streaming when a stop arrives.
56
+ const controller = yield* Effect.acquireRelease(
57
+ Effect.sync(() => new AbortController()),
58
+ controller => Effect.sync(() => controller.abort()),
59
+ )
60
+ let contentSha256: string | undefined
61
+ if (verifyRegisteredDigest) {
62
+ const detailResponse = yield* request(`/api/cli/runner-versions/${encodeURIComponent(runnerVersionId)}`, controller.signal)
63
+ if (!detailResponse.ok) return yield* Effect.fail(new Error(`Failed to resolve runner version: ${yield* text(detailResponse)}`))
64
+ const detail = (yield* Effect.tryPromise({ try: () => detailResponse.json(), catch: error => error })) as Record<string, unknown>
65
+ if (detail.runnerVersionId !== runnerVersionId || detail.status !== 'sealed'
66
+ || typeof detail.contentSha256 !== 'string' || !/^[0-9a-f]{64}$/u.test(detail.contentSha256)) {
67
+ return yield* Effect.fail(new Error('Runner version detail is invalid'))
68
+ }
69
+ contentSha256 = detail.contentSha256
70
+ }
71
+ const response = yield* request(`/api/cli/runner-versions/${encodeURIComponent(runnerVersionId)}/download`, controller.signal)
72
+ if (!response.ok) return yield* Effect.fail(new Error(`Failed to download runner version: ${yield* text(response)}`))
73
+ // The previous implementation removed the folder as soon as this runner
74
+ // failed, naming the runner when that removal failed too. Keep both.
75
+ let removed = false
76
+ let tempDir = ''
77
+ const remove = () => { if (!removed) { removed = true; rmSync(tempDir, { recursive: true, force: true }) } }
78
+ // Created and registered for removal in one uninterruptible step.
79
+ yield* Effect.acquireRelease(
80
+ Effect.sync(() => { tempDir = mkdtempSync(join(tmpdir(), 'orizu-runner-version-')) }),
81
+ () => Effect.sync(() => {
82
+ try { remove() } catch (error) { ledger.failures.push({ group: 'downloads', error }) }
83
+ }),
84
+ )
85
+ return yield* Effect.gen(function* () {
86
+ const zipBytes = new Uint8Array(yield* Effect.tryPromise({ try: () => response.arrayBuffer(), catch: error => error }))
87
+ if (zipBytes.byteLength > ARTIFACT_MAX_BYTES) return yield* Effect.fail(new Error(`Runner artifact exceeds ${ARTIFACT_MAX_BYTES} bytes`))
88
+ if (contentSha256 && createHash('sha256').update(zipBytes).digest('hex') !== contentSha256) {
89
+ return yield* Effect.fail(new Error('Runner artifact digest mismatch'))
90
+ }
91
+ const zipPath = join(tempDir, 'runner.zip'), runnerDir = join(tempDir, 'runner')
92
+ yield* Effect.try({ try: () => { writeFileSync(zipPath, zipBytes); unzipRunnerArchive(zipPath, runnerDir) }, catch: error => error })
93
+ return { runnerDir, remove }
94
+ }).pipe(Effect.catch(error => Effect.suspend(() => {
95
+ try { remove() } catch (cleanupError) {
96
+ return Effect.fail(new AggregateError([error, cleanupError], `Runner ${runnerVersionId} materialization failed and cleanup also failed`))
97
+ }
98
+ return Effect.fail(error)
99
+ })))
100
+ })
101
+ }
102
+
103
+ /** A Promise-returning resource (acquired whole) whose cleanup the scope runs. */
104
+ export function heldUntilClose<A extends { cleanup: () => void }>(ledger: RemovalLedger, group: RemovalGroup, acquire: () => Promise<A>) {
105
+ // Acquiring stays uninterruptible: a stop waits for it to finish, so a
106
+ // half-created snapshot is never orphaned; the scope then removes it.
107
+ return Effect.acquireRelease(
108
+ Effect.tryPromise({ try: acquire, catch: error => error }),
109
+ resource => Effect.sync(() => {
110
+ try { resource.cleanup() } catch (error) { ledger.failures.push({ group, error }) }
111
+ }),
112
+ )
113
+ }
114
+
115
+ const STOP_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGQUIT'] as const
116
+ // Matches the launcher's stop cap (ORI-2402): a stop never waits longer.
117
+ const STOP_CLEANUP_WAIT_MS = 15_000
118
+
119
+ export interface StopControl {
120
+ /** Hand stop signals to the optimizer launcher; pass this as its beforeSignalExit. */
121
+ readonly beforeSignalExit: () => Promise<void> | void
122
+ readonly launcherOwnsStop: () => void
123
+ readonly launcherDone: () => void
124
+ }
125
+
126
+ /**
127
+ * Run `program` in a scope that removes the runner copies however it ends.
128
+ * Before the optimizer starts, a stop signal interrupts the program (the scope
129
+ * closes as it unwinds) and the CLI then ends by that signal. While the
130
+ * launcher runs the optimizer, the launcher owns the signal and calls
131
+ * `beforeSignalExit`, which closes the scope synchronously: the removal then
132
+ * finishes even when the optimizer used up the whole stop cap.
133
+ */
134
+ export async function runStoppable<A>(program: (stop: StopControl) => Effect.Effect<A, unknown, Scope.Scope>): Promise<A> {
135
+ const { scope, close } = openScope()
136
+ let launcherOwned = false
137
+ let stopping = false
138
+ // eslint-disable-next-line prefer-const -- assigned after the handlers that read it
139
+ let fiber: Fiber.Fiber<A, unknown>
140
+ const interrupt = () => Effect.runPromise(Fiber.interrupt(fiber))
141
+ const removeHandlers = () => { for (const entry of handlers) process.off(entry.signal, entry.handle) }
142
+ const handlers = STOP_SIGNALS.map(signal => ({
143
+ signal,
144
+ handle: () => {
145
+ if (launcherOwned || stopping) return
146
+ stopping = true
147
+ let timer: ReturnType<typeof setTimeout> | undefined
148
+ const expired = new Promise<void>(resolve => { timer = setTimeout(resolve, STOP_CLEANUP_WAIT_MS) })
149
+ void Promise.race([interrupt(), expired]).finally(() => {
150
+ clearTimeout(timer)
151
+ removeHandlers()
152
+ process.kill(process.pid, signal)
153
+ })
154
+ },
155
+ }))
156
+ const control: StopControl = {
157
+ beforeSignalExit: () => {
158
+ stopping = true
159
+ // The launcher re-sends the signal next; none of ours may catch it.
160
+ removeHandlers()
161
+ close()
162
+ },
163
+ launcherOwnsStop: () => { launcherOwned = true },
164
+ launcherDone: () => { launcherOwned = false },
165
+ }
166
+ for (const entry of handlers) process.on(entry.signal, entry.handle)
167
+ fiber = Effect.runFork(Scope.provide(scope)(program(control)).pipe(Effect.ensuring(Effect.sync(close))))
168
+ try {
169
+ const exit = await Effect.runPromise(Effect.exit(Fiber.join(fiber)))
170
+ // A stop ends the process by its signal; nothing more to report here.
171
+ if (stopping) return await new Promise<never>(() => {})
172
+ if (Exit.isSuccess(exit)) return exit.value
173
+ throw Cause.squash(exit.cause)
174
+ } finally {
175
+ removeHandlers()
176
+ }
177
+ }
178
+
179
+ /**
180
+ * For a workflow that stays a Promise (hosted optimization): a scope that a
181
+ * stop signal closes before the CLI ends by that signal. Before the optimizer
182
+ * starts our handlers do it; while the launcher runs it, the launcher calls
183
+ * `stop.beforeSignalExit`. Closing aborts an in-flight download and removes
184
+ * every registered file, folder and server; the finalizers are synchronous.
185
+ */
186
+ export function stopClosesScope() {
187
+ const { scope, close } = openScope()
188
+ let launcherOwned = false
189
+ let stopping = false
190
+ const removeHandlers = () => { for (const entry of handlers) process.off(entry.signal, entry.handle) }
191
+ const handlers = STOP_SIGNALS.map(signal => ({
192
+ signal,
193
+ handle: () => {
194
+ if (launcherOwned || stopping) return
195
+ stopping = true
196
+ close()
197
+ removeHandlers()
198
+ process.kill(process.pid, signal)
199
+ },
200
+ }))
201
+ // Installed by the caller inside its try, so a setup throw cannot leave them behind.
202
+ const listen = () => { for (const entry of handlers) process.on(entry.signal, entry.handle) }
203
+ const stop: StopControl = {
204
+ beforeSignalExit: () => { stopping = true; removeHandlers(); close() },
205
+ launcherOwnsStop: () => { launcherOwned = true },
206
+ launcherDone: () => { launcherOwned = false },
207
+ }
208
+ return { scope, close, stop, listen, removeHandlers }
209
+ }
210
+
211
+ /** Open a scope for an adapter that must hand back a synchronous cleanup. */
212
+ export function openScope() {
213
+ const scope = Scope.makeUnsafe()
214
+ let closed = false
215
+ return {
216
+ scope,
217
+ close: () => {
218
+ if (closed) return
219
+ closed = true
220
+ Effect.runSync(Scope.close(scope, Exit.void))
221
+ },
222
+ }
223
+ }
@@ -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: string, runnerDir: string): void {
3
+ export function unzipRunnerArchive(zipPath: string, runnerDir: string): void {
11
4
  const listing = spawnSync('unzip', ['-Z1', zipPath], { encoding: 'utf8' })
12
5
  if (listing.error) throw listing.error
13
6
  if (listing.status !== 0) throw new Error(`unzip listing failed: ${sanitizeTerminalText(listing.stderr || listing.stdout || '')}`)
@@ -24,33 +17,27 @@ function unzip(zipPath: string, runnerDir: string): void {
24
17
  export async function materializeRunnerVersion(runnerVersionId: string, verifyRegisteredDigest = true): Promise<{
25
18
  runnerDir: string; cleanup: () => void
26
19
  }> {
27
- let contentSha256: string | undefined
28
- if (verifyRegisteredDigest) {
29
- const detailResponse = await authedFetch(`/api/cli/runner-versions/${encodeURIComponent(runnerVersionId)}`)
30
- if (!detailResponse.ok) throw new Error(`Failed to resolve runner version: ${await detailResponse.text()}`)
31
- const detail = await detailResponse.json() as Record<string, unknown>
32
- if (detail.runnerVersionId !== runnerVersionId || detail.status !== 'sealed'
33
- || typeof detail.contentSha256 !== 'string' || !/^[0-9a-f]{64}$/u.test(detail.contentSha256)) {
34
- throw new Error('Runner version detail is invalid')
35
- }
36
- contentSha256 = detail.contentSha256
37
- }
38
- const response = await authedFetch(`/api/cli/runner-versions/${encodeURIComponent(runnerVersionId)}/download`)
39
- if (!response.ok) throw new Error(`Failed to download runner version: ${await response.text()}`)
40
- const tempDir = mkdtempSync(join(tmpdir(), 'orizu-runner-version-'))
41
- const cleanup = () => rmSync(tempDir, { recursive: true, force: true })
20
+ // ORI-2429: the download runs on Effect (runner-folders.ts); this adapter
21
+ // keeps the { runnerDir, cleanup } shape for the existing Promise callers.
22
+ const { downloadedRunner, openScope, RemovalLedger } = await import('./runner-folders.js')
23
+ const { Effect, Scope } = await import('effect')
24
+ const ledger = new RemovalLedger()
25
+ const { scope, close } = openScope()
42
26
  try {
43
- const zipBytes = new Uint8Array(await response.arrayBuffer())
44
- if (zipBytes.byteLength > ARTIFACT_MAX_BYTES) throw new Error(`Runner artifact exceeds ${ARTIFACT_MAX_BYTES} bytes`)
45
- if (contentSha256 && createHash('sha256').update(zipBytes).digest('hex') !== contentSha256) throw new Error('Runner artifact digest mismatch')
46
- const zipPath = join(tempDir, 'runner.zip'), runnerDir = join(tempDir, 'runner')
47
- writeFileSync(zipPath, zipBytes)
48
- unzip(zipPath, runnerDir)
49
- return { runnerDir, cleanup }
27
+ const { runnerDir } = await Effect.runPromise(
28
+ downloadedRunner(runnerVersionId, ledger, verifyRegisteredDigest).pipe(Effect.provideService(Scope.Scope, scope))
29
+ )
30
+ return {
31
+ runnerDir,
32
+ cleanup: () => {
33
+ close()
34
+ const [failure] = ledger.errors()
35
+ if (failure !== undefined) throw failure
36
+ },
37
+ }
50
38
  } catch (error) {
51
- throwWithCleanup(error, [cleanup], `Runner ${runnerVersionId} materialization failed and cleanup also failed`)
39
+ // downloadedRunner already removed this runner's folder on its own failure.
40
+ close()
41
+ throw error
52
42
  }
53
43
  }
54
- export function cleanupMaterializedRunners(cleanups: Array<() => void>): void {
55
- cleanupAll(cleanups, 'Failed to clean materialized runner directories')
56
- }
@@ -54,6 +54,29 @@ const STOP_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGQUIT'] as const
54
54
  // status write); past this it is killed so nothing is left running (ORI-2402).
55
55
  const CHILD_STOP_WAIT_MS = 15_000
56
56
 
57
+ export interface SkilledProposerStopOptions {
58
+ /**
59
+ * Runs once the optimizer has exited after a stop signal, just before the
60
+ * CLI ends by that signal (ORI-2429: the caller removes its runner copies).
61
+ * It gets whatever is left of CHILD_STOP_WAIT_MS since the signal; a hook
62
+ * still running then is abandoned so the stop cannot hang. A synchronous
63
+ * hook always runs to the end, even with no time left.
64
+ */
65
+ beforeSignalExit?: () => Promise<void> | void
66
+ }
67
+
68
+ // Wait for `hook` at most until `deadline`; its failure never blocks the exit.
69
+ async function runBeforeSignalExit(hook: SkilledProposerStopOptions['beforeSignalExit'], deadline: number): Promise<void> {
70
+ if (!hook) return
71
+ let timer: ReturnType<typeof setTimeout> | undefined
72
+ const expired = new Promise<void>(resolve => { timer = setTimeout(resolve, Math.max(0, deadline - Date.now())) })
73
+ try {
74
+ await Promise.race([Promise.resolve().then(hook).catch(() => {}), expired])
75
+ } finally {
76
+ clearTimeout(timer)
77
+ }
78
+ }
79
+
57
80
  /**
58
81
  * Run the optimizer, passing a stop signal on to it. On a stop signal the CLI
59
82
  * waits until the optimizer exits (capped at CHILD_STOP_WAIT_MS, then kills
@@ -63,11 +86,13 @@ export async function spawnSkilledProposerChild(
63
86
  command: string, args: string[], engine: GepaEngine, environment: NodeJS.ProcessEnv,
64
87
  onSpawn?: () => Promise<void>,
65
88
  output?: SkilledProposerChildOutput,
89
+ stopOptions: SkilledProposerStopOptions = {},
66
90
  ) {
67
91
  const childEnvironment = { ...environment }
68
92
  let child: ReturnType<typeof spawn> | PipedChild | undefined
69
93
  let cleanup = () => {}
70
94
  let stopSignal: NodeJS.Signals | undefined
95
+ let stopDeadline = 0
71
96
  // On the terminal path the optimizer leads its own process group, so a stop
72
97
  // also reaches its runners, which may run in worker threads it can't stop.
73
98
  let childGroup: number | undefined
@@ -79,10 +104,16 @@ export async function spawnSkilledProposerChild(
79
104
  }
80
105
  let giveUp: ReturnType<typeof setTimeout> | undefined
81
106
  const removeHandlers = () => { for (const entry of handlers) process.off(entry.signal, entry.handle) }
82
- const endBySignal = (signal: NodeJS.Signals): Promise<never> => {
107
+ let ending = false
108
+ const endBySignal = async (signal: NodeJS.Signals): Promise<never> => {
109
+ if (ending) return new Promise<never>(() => {})
110
+ ending = true
83
111
  if (giveUp) clearTimeout(giveUp)
84
- removeHandlers()
85
112
  cleanup()
113
+ // Our handlers stay installed during the caller's cleanup, so a second
114
+ // Ctrl-C then is absorbed instead of ending the CLI by default mid-cleanup.
115
+ await runBeforeSignalExit(stopOptions.beforeSignalExit, stopDeadline)
116
+ removeHandlers()
86
117
  // With our handlers gone, the signal's default action ends the process.
87
118
  process.kill(process.pid, signal)
88
119
  return new Promise<never>(() => {})
@@ -90,6 +121,7 @@ export async function spawnSkilledProposerChild(
90
121
  const handlers = STOP_SIGNALS.map(signal => ({
91
122
  signal,
92
123
  handle: () => {
124
+ if (!stopDeadline) stopDeadline = Date.now() + CHILD_STOP_WAIT_MS
93
125
  if (!child) { void endBySignal(signal); return }
94
126
  forward(signal)
95
127
  if (stopSignal) return
@@ -104,6 +136,8 @@ export async function spawnSkilledProposerChild(
104
136
  try {
105
137
  cleanup = materializeConfigPayload(engine, childEnvironment) ?? cleanup
106
138
  await new Promise<void>(resolve => setImmediate(resolve))
139
+ // A stop before the optimizer starts ends the CLI (endBySignal); never start it then.
140
+ if (stopDeadline) return await new Promise<never>(() => {})
107
141
  let result: { status: number | null, error?: Error }
108
142
  if (output) {
109
143
  result = await runPipedChild(
@@ -112,7 +146,11 @@ export async function spawnSkilledProposerChild(
112
146
  childEnvironment,
113
147
  async () => { await onSpawn?.() },
114
148
  output,
115
- spawnedChild => { child = spawnedChild }
149
+ spawnedChild => {
150
+ child = spawnedChild
151
+ // Stopped while the output sinks were prepared: the CLI is ending.
152
+ if (stopDeadline && !stopSignal) spawnedChild.kill('SIGKILL')
153
+ }
116
154
  )
117
155
  } else {
118
156
  // stdin is not passed on: a process outside the terminal's foreground