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
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
|
-
|
|
1334
|
-
//
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
//
|
|
1339
|
-
|
|
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
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1388
|
-
|
|
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
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 => {
|
|
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
|