orizu 0.5.43 → 0.5.45
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-agent-prompt.d.ts +6 -0
- package/dist/hosted-agent-prompt.d.ts.map +1 -1
- package/dist/hosted-agent-prompt.js +11 -0
- package/dist/hosted-boot.d.ts +3 -0
- package/dist/hosted-boot.d.ts.map +1 -1
- package/dist/hosted-boot.js +14 -0
- package/dist/hosted-bootstrap-rehearsal.d.ts +2 -0
- package/dist/hosted-bootstrap-rehearsal.d.ts.map +1 -1
- package/dist/hosted-bootstrap-rehearsal.js +5 -0
- package/dist/hosted-broker-server.d.ts +2 -0
- package/dist/hosted-broker-server.d.ts.map +1 -1
- package/dist/hosted-broker-server.js +4 -1
- package/dist/hosted-harness-opencode.d.ts +2 -0
- package/dist/hosted-harness-opencode.d.ts.map +1 -1
- package/dist/hosted-harness-opencode.js +2 -2
- package/dist/hosted-loop-lifecycle.d.ts +2 -0
- package/dist/hosted-loop-lifecycle.d.ts.map +1 -1
- package/dist/hosted-loop.d.ts.map +1 -1
- package/dist/hosted-loop.js +3 -0
- package/dist/hosted-project-folder-seed.d.ts +18 -0
- package/dist/hosted-project-folder-seed.d.ts.map +1 -0
- package/dist/hosted-project-folder-seed.js +78 -0
- package/dist/hosted-session-cli.d.ts.map +1 -1
- package/dist/hosted-session-cli.js +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -8
- package/dist/score-save-workflow.d.ts +10 -14
- package/dist/score-save-workflow.d.ts.map +1 -1
- package/dist/score-save-workflow.js +14 -11
- package/dist/secret-redaction.d.ts +14 -0
- package/dist/secret-redaction.d.ts.map +1 -1
- package/dist/secret-redaction.js +25 -0
- package/dist/skilled-proposer-launch.d.ts +5 -0
- package/dist/skilled-proposer-launch.d.ts.map +1 -1
- package/dist/skilled-proposer-launch.js +104 -46
- package/package.json +1 -1
- package/src/hosted-agent-prompt.ts +11 -0
- package/src/hosted-boot.ts +18 -0
- package/src/hosted-bootstrap-rehearsal.ts +6 -0
- package/src/hosted-broker-server.ts +5 -1
- package/src/hosted-harness-opencode.ts +4 -2
- package/src/hosted-loop-lifecycle.ts +2 -0
- package/src/hosted-loop.ts +2 -0
- package/src/hosted-project-folder-seed.ts +95 -0
- package/src/hosted-session-cli.ts +3 -1
- package/src/index.ts +9 -11
- package/src/score-save-workflow.ts +21 -16
- package/src/secret-redaction.ts +30 -0
- package/src/skilled-proposer-launch.ts +78 -36
- package/vendor/orizu-gepa/src/orizu_gepa_connector/runtime.py +123 -0
package/src/index.ts
CHANGED
|
@@ -796,9 +796,13 @@ async function submitRunResults() {
|
|
|
796
796
|
const resultsJsonl = resultsPath.endsWith('.gz')
|
|
797
797
|
? gunzipSync(resultBytes).toString('utf8')
|
|
798
798
|
: resultBytes.toString('utf8')
|
|
799
|
-
const
|
|
800
|
-
|
|
801
|
-
|
|
799
|
+
const { sendScoreSave } = await import('./score-save-workflow.js')
|
|
800
|
+
const data = await sendScoreSave({
|
|
801
|
+
command: 'run',
|
|
802
|
+
path: `/api/cli/runs/submit?project=${encodeURIComponent(project)}`,
|
|
803
|
+
project,
|
|
804
|
+
promptVersionId,
|
|
805
|
+
saves: true,
|
|
802
806
|
body: JSON.stringify({
|
|
803
807
|
promptVersionId,
|
|
804
808
|
runnerVersionId,
|
|
@@ -810,19 +814,13 @@ async function submitRunResults() {
|
|
|
810
814
|
resultsJsonl,
|
|
811
815
|
...instructionSetProvenanceFields(project),
|
|
812
816
|
}),
|
|
813
|
-
})
|
|
814
|
-
|
|
815
|
-
if (!response.ok) {
|
|
816
|
-
throw new Error(`Failed to submit run: ${await response.text()}`)
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
const data = await parseJsonResponse<{
|
|
817
|
+
}, authedFetch) as {
|
|
820
818
|
run: {
|
|
821
819
|
id: string
|
|
822
820
|
aggregateScore: number | null
|
|
823
821
|
perRowResultsStoragePath: string
|
|
824
822
|
}
|
|
825
|
-
}
|
|
823
|
+
}
|
|
826
824
|
if (hasJsonFlag()) {
|
|
827
825
|
printJson({ run: data.run })
|
|
828
826
|
return
|
|
@@ -8,34 +8,39 @@ import { appErrorOf, unreachedCode } from './request-outcome.js'
|
|
|
8
8
|
|
|
9
9
|
// Loaded only through `await import(...)`, like the dataset workflows.
|
|
10
10
|
|
|
11
|
+
const SavedScore = Schema.Struct({ scoreRun: Schema.Struct({ id: Schema.NonEmptyString }) })
|
|
12
|
+
const SavedRun = Schema.Struct({ run: Schema.Struct({ id: Schema.NonEmptyString }) })
|
|
13
|
+
|
|
14
|
+
// `serverMayBeSaved` is what the route says when it cannot tell whether the run was saved.
|
|
11
15
|
const COMMANDS = {
|
|
12
|
-
submit: { label: 'Score submit', failed: 'Failed to submit score' },
|
|
13
|
-
exec: { label: 'Scorer exec', failed: 'Failed to execute scorer' },
|
|
16
|
+
submit: { label: 'Score submit', failed: 'Failed to submit score', serverMayBeSaved: 'Score run may already be saved', saved: SavedScore },
|
|
17
|
+
exec: { label: 'Scorer exec', failed: 'Failed to execute scorer', serverMayBeSaved: 'Score run may already be saved', saved: SavedScore },
|
|
18
|
+
run: { label: 'Run submit', failed: 'Failed to submit run', serverMayBeSaved: 'Run may already be saved', saved: SavedRun },
|
|
14
19
|
} as const
|
|
15
20
|
|
|
16
|
-
// What the server says when it cannot tell whether the run was saved.
|
|
17
|
-
const SERVER_MAY_BE_SAVED = 'Score run may already be saved'
|
|
18
21
|
// Node's fetch publishes this once a request's whole body has been written.
|
|
19
22
|
const BODY_SENT = 'undici:request:bodySent'
|
|
20
23
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
export interface ScoreSave {
|
|
24
|
-
command: keyof typeof COMMANDS
|
|
24
|
+
interface SaveRequest {
|
|
25
25
|
/** The route path with its query, as sent. */
|
|
26
26
|
path: string
|
|
27
27
|
body: string
|
|
28
28
|
project: string
|
|
29
|
-
scorerVersionId: string
|
|
30
29
|
/** False for `scorers exec --no-submit`, which saves nothing. */
|
|
31
30
|
saves: boolean
|
|
32
31
|
}
|
|
33
32
|
|
|
33
|
+
/** `scores submit` and `scorers exec`, or `runs submit` (ORI-1097 S3b). */
|
|
34
|
+
export type ScoreSave =
|
|
35
|
+
| SaveRequest & { command: 'submit' | 'exec'; scorerVersionId: string }
|
|
36
|
+
| SaveRequest & { command: 'run'; promptVersionId: string }
|
|
37
|
+
|
|
34
38
|
/** Sends one request; calls `onRequestStart` just before anything goes out. */
|
|
35
39
|
export type ScoreSaveRequest = (path: string, init: RequestInit, onRequestStart?: () => void) => Promise<Response>
|
|
36
40
|
|
|
37
|
-
const
|
|
38
|
-
`The
|
|
41
|
+
const mayBeSaved = (save: ScoreSave) => save.command === 'run'
|
|
42
|
+
? `The run may already be saved: run \`orizu runs list --project ${save.project} --prompt-version ${save.promptVersionId}\` and check its newest runs before running this again.`
|
|
43
|
+
: `The score may already be saved: run \`orizu scorers detail ${save.scorerVersionId} --project ${save.project}\` and check its newest score runs before running this again.`
|
|
39
44
|
|
|
40
45
|
// The run may be saved when the server says so, when the reply was lost, late
|
|
41
46
|
// or unreadable after the request was sent, when a 5xx did not come from the
|
|
@@ -50,7 +55,7 @@ class ScoreSaveFailure extends Error {
|
|
|
50
55
|
// second run. The deadline starts once the whole body has been sent, so a
|
|
51
56
|
// large upload on a slow link takes as long as it did before.
|
|
52
57
|
function scoreSave(save: ScoreSave, request: ScoreSaveRequest, timeoutMs: number): Effect.Effect<Record<string, unknown>, ScoreSaveFailure> {
|
|
53
|
-
const { label, failed } = COMMANDS[save.command]
|
|
58
|
+
const { label, failed, serverMayBeSaved, saved } = COMMANDS[save.command]
|
|
54
59
|
return Effect.suspend(() => {
|
|
55
60
|
let sent = false
|
|
56
61
|
let timedOut = false
|
|
@@ -79,7 +84,7 @@ function scoreSave(save: ScoreSave, request: ScoreSaveRequest, timeoutMs: number
|
|
|
79
84
|
if (!response.ok) {
|
|
80
85
|
const appError = await appErrorOf(response.clone())
|
|
81
86
|
const detail = await extractErrorMessage(response)
|
|
82
|
-
if (appError ===
|
|
87
|
+
if (appError === serverMayBeSaved) throw new ScoreSaveFailure(appError, true)
|
|
83
88
|
if (response.status < 400 || (response.status >= 500 && appError === null)) {
|
|
84
89
|
throw new ScoreSaveFailure(`${label} got no clear answer (status ${response.status}).`, true)
|
|
85
90
|
}
|
|
@@ -94,7 +99,7 @@ function scoreSave(save: ScoreSave, request: ScoreSaveRequest, timeoutMs: number
|
|
|
94
99
|
data = undefined
|
|
95
100
|
}
|
|
96
101
|
const usable = save.saves
|
|
97
|
-
? Result.isSuccess(Schema.decodeUnknownResult(
|
|
102
|
+
? Result.isSuccess(Schema.decodeUnknownResult(saved)(data))
|
|
98
103
|
: typeof data === 'object' && data !== null && !Array.isArray(data)
|
|
99
104
|
if (!usable) {
|
|
100
105
|
throw new ScoreSaveFailure(`${label} returned an unexpected response (status ${response.status}).`, true)
|
|
@@ -122,10 +127,10 @@ function scoreSave(save: ScoreSave, request: ScoreSaveRequest, timeoutMs: number
|
|
|
122
127
|
})
|
|
123
128
|
}
|
|
124
129
|
|
|
125
|
-
/** `orizu scores submit` and `orizu
|
|
130
|
+
/** `orizu scores submit`, `orizu scorers exec` and `orizu runs submit`: the reply, or an error saying whether the save may have happened. */
|
|
126
131
|
export async function sendScoreSave(save: ScoreSave, request: ScoreSaveRequest): Promise<Record<string, unknown>> {
|
|
127
132
|
const exit = await Effect.runPromiseExit(scoreSave(save, request, datasetTransferTimeoutMs()).pipe(
|
|
128
|
-
Effect.mapError(failure => new Error(failure.mayBeSaved && save.saves ? `${failure.message}\n${
|
|
133
|
+
Effect.mapError(failure => new Error(failure.mayBeSaved && save.saves ? `${failure.message}\n${mayBeSaved(save)}` : failure.message)),
|
|
129
134
|
))
|
|
130
135
|
if (Exit.isSuccess(exit)) return exit.value
|
|
131
136
|
throw Cause.squash(exit.cause)
|
package/src/secret-redaction.ts
CHANGED
|
@@ -76,6 +76,36 @@ function redactString(input: string, secrets: readonly string[]): string {
|
|
|
76
76
|
return out
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* The shape pass alone, for servers that hold no secret values: the web app
|
|
81
|
+
* and the session coordinator scrub agent text with it before it reaches a
|
|
82
|
+
* browser (ORI-1905). One list of shapes for the CLI and both servers.
|
|
83
|
+
*/
|
|
84
|
+
export function redactSecretShapes(input: string): string {
|
|
85
|
+
return redactString(input, [])
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// A known credential prefix at the end of the text, followed by fewer
|
|
89
|
+
// characters than its shape above needs: the start of a token still being
|
|
90
|
+
// written. Each run is bounded, so the scan stays linear in the text, and a
|
|
91
|
+
// longer run that is not a token (a path, a word) is left alone. `Bearer` only
|
|
92
|
+
// counts in an Authorization header, so prose like "a Bearer token" keeps its
|
|
93
|
+
// last word.
|
|
94
|
+
const OPEN_TOKEN_AT_END = /(?:\bgithub_pat_[A-Za-z0-9_]{0,19}|\bgh[posur]_[A-Za-z0-9_]{0,19}|\borizu_(?:agent|pat)_[A-Za-z0-9_-]{0,9}|\bsk-[A-Za-z0-9_-]{0,19}|\b(authorization:\s*bearer)\s+[A-Za-z0-9._~+/-]{0,15})$/i
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* For reply text shown as it is written: each snapshot is the reply so far, so
|
|
98
|
+
* one can end part-way through a token, before it is long enough for its shape
|
|
99
|
+
* to match. Such a tail is redacted too. The saved read shows a reply's last
|
|
100
|
+
* snapshot, so a finished reply that really ends in a bare `sk-` loses those
|
|
101
|
+
* characters there as well.
|
|
102
|
+
*/
|
|
103
|
+
export function redactStreamedTextShapes(input: string): string {
|
|
104
|
+
return redactSecretShapes(input).replace(OPEN_TOKEN_AT_END, (_match, header?: string) =>
|
|
105
|
+
header ? `${header} ${REDACTION_PLACEHOLDER}` : REDACTION_PLACEHOLDER
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
79
109
|
export interface RedactOptions {
|
|
80
110
|
/** Verbatim secret values to strip wherever they appear (bearer, tokens). */
|
|
81
111
|
secrets?: readonly string[]
|
|
@@ -49,39 +49,64 @@ function materializeConfigPayload(engine: GepaEngine, environment: NodeJS.Proces
|
|
|
49
49
|
return () => rmSync(root, { recursive: true, force: true })
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
const STOP_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGQUIT'] as const
|
|
53
|
+
// The optimizer marks its run stopped before exiting (at most 10 s for the
|
|
54
|
+
// status write); past this it is killed so nothing is left running (ORI-2402).
|
|
55
|
+
const CHILD_STOP_WAIT_MS = 15_000
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Run the optimizer, passing a stop signal on to it. On a stop signal the CLI
|
|
59
|
+
* waits until the optimizer exits (capped at CHILD_STOP_WAIT_MS, then kills
|
|
60
|
+
* it) and then ends by that same signal.
|
|
61
|
+
*/
|
|
52
62
|
export async function spawnSkilledProposerChild(
|
|
53
63
|
command: string, args: string[], engine: GepaEngine, environment: NodeJS.ProcessEnv,
|
|
54
64
|
onSpawn?: () => Promise<void>,
|
|
55
65
|
output?: SkilledProposerChildOutput,
|
|
56
66
|
) {
|
|
57
67
|
const childEnvironment = { ...environment }
|
|
58
|
-
const shouldMaterialize = engine === 'official'
|
|
59
|
-
&& childEnvironment.ORIZU_CANDIDATE_PROPOSER === 'skilled-proposer'
|
|
60
|
-
&& childEnvironment.ORIZU_SKILLED_PROPOSER_CONFIG !== undefined
|
|
61
|
-
if (!shouldMaterialize) return spawnSync(command, args, { stdio: 'inherit', env: childEnvironment })
|
|
62
|
-
|
|
63
68
|
let child: ReturnType<typeof spawn> | PipedChild | undefined
|
|
64
69
|
let cleanup = () => {}
|
|
65
|
-
|
|
66
|
-
|
|
70
|
+
let stopSignal: NodeJS.Signals | undefined
|
|
71
|
+
// On the terminal path the optimizer leads its own process group, so a stop
|
|
72
|
+
// also reaches its runners, which may run in worker threads it can't stop.
|
|
73
|
+
let childGroup: number | undefined
|
|
74
|
+
const forward = (signal: NodeJS.Signals) => {
|
|
75
|
+
if (childGroup) {
|
|
76
|
+
try { process.kill(-childGroup, signal); return } catch { /* group already gone */ }
|
|
77
|
+
}
|
|
78
|
+
try { child?.kill(signal) } catch { /* already exited */ }
|
|
79
|
+
}
|
|
80
|
+
let giveUp: ReturnType<typeof setTimeout> | undefined
|
|
81
|
+
const removeHandlers = () => { for (const entry of handlers) process.off(entry.signal, entry.handle) }
|
|
82
|
+
const endBySignal = (signal: NodeJS.Signals): Promise<never> => {
|
|
83
|
+
if (giveUp) clearTimeout(giveUp)
|
|
84
|
+
removeHandlers()
|
|
85
|
+
cleanup()
|
|
86
|
+
// With our handlers gone, the signal's default action ends the process.
|
|
87
|
+
process.kill(process.pid, signal)
|
|
88
|
+
return new Promise<never>(() => {})
|
|
89
|
+
}
|
|
90
|
+
const handlers = STOP_SIGNALS.map(signal => ({
|
|
67
91
|
signal,
|
|
68
92
|
handle: () => {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
93
|
+
if (!child) { void endBySignal(signal); return }
|
|
94
|
+
forward(signal)
|
|
95
|
+
if (stopSignal) return
|
|
96
|
+
stopSignal = signal
|
|
97
|
+
// The child read the private payload at start and a stopping run
|
|
98
|
+
// proposes nothing, so it goes now rather than when the child exits.
|
|
99
|
+
cleanup()
|
|
100
|
+
giveUp = setTimeout(() => forward('SIGKILL'), CHILD_STOP_WAIT_MS)
|
|
77
101
|
},
|
|
78
102
|
}))
|
|
79
|
-
for (const entry of handlers) process.
|
|
103
|
+
for (const entry of handlers) process.on(entry.signal, entry.handle)
|
|
80
104
|
try {
|
|
81
105
|
cleanup = materializeConfigPayload(engine, childEnvironment) ?? cleanup
|
|
82
106
|
await new Promise<void>(resolve => setImmediate(resolve))
|
|
107
|
+
let result: { status: number | null, error?: Error }
|
|
83
108
|
if (output) {
|
|
84
|
-
|
|
109
|
+
result = await runPipedChild(
|
|
85
110
|
command,
|
|
86
111
|
args,
|
|
87
112
|
childEnvironment,
|
|
@@ -89,27 +114,44 @@ export async function spawnSkilledProposerChild(
|
|
|
89
114
|
output,
|
|
90
115
|
spawnedChild => { child = spawnedChild }
|
|
91
116
|
)
|
|
117
|
+
} else {
|
|
118
|
+
// stdin is not passed on: a process outside the terminal's foreground
|
|
119
|
+
// group that read the terminal would be stopped by it.
|
|
120
|
+
// Outside the CLI's group, the optimizer would outlive a SIGKILL of the
|
|
121
|
+
// CLI; it watches for that and stops itself.
|
|
122
|
+
const spawnedChild = spawn(command, args, {
|
|
123
|
+
stdio: ['ignore', 'inherit', 'inherit'],
|
|
124
|
+
env: { ...childEnvironment, ORIZU_STOP_WITH_PARENT: '1' },
|
|
125
|
+
detached: true,
|
|
126
|
+
})
|
|
127
|
+
child = spawnedChild
|
|
128
|
+
childGroup = spawnedChild.pid
|
|
129
|
+
const spawned = new Promise<void>((resolve, reject) => {
|
|
130
|
+
spawnedChild.once('spawn', resolve)
|
|
131
|
+
spawnedChild.once('error', reject)
|
|
132
|
+
})
|
|
133
|
+
const closed = new Promise<{ status: number | null, error?: Error }>(resolve => {
|
|
134
|
+
let error: Error | undefined
|
|
135
|
+
spawnedChild.once('error', cause => { error = cause })
|
|
136
|
+
spawnedChild.once('close', status => resolve(error ? { status, error } : { status }))
|
|
137
|
+
})
|
|
138
|
+
// A launch failure (e.g. ENOENT) is returned, not thrown, so callers
|
|
139
|
+
// name the engine as they did with spawnSync.
|
|
140
|
+
try { await spawned }
|
|
141
|
+
catch (error) { return { status: null, error: error as Error } }
|
|
142
|
+
try { await onSpawn?.() }
|
|
143
|
+
catch (error) { spawnedChild.kill('SIGTERM'); await closed; throw error }
|
|
144
|
+
result = await closed
|
|
145
|
+
// A runner that ignored the stop must not outlive the optimizer.
|
|
146
|
+
if (stopSignal) {
|
|
147
|
+
try { process.kill(-spawnedChild.pid!, 'SIGKILL') } catch { /* group already gone */ }
|
|
148
|
+
}
|
|
92
149
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
env: childEnvironment,
|
|
96
|
-
})
|
|
97
|
-
child = spawnedChild
|
|
98
|
-
const spawned = new Promise<void>((resolve, reject) => {
|
|
99
|
-
spawnedChild.once('spawn', resolve)
|
|
100
|
-
spawnedChild.once('error', reject)
|
|
101
|
-
})
|
|
102
|
-
const closed = new Promise<{ status: number | null, error?: Error }>(resolve => {
|
|
103
|
-
let error: Error | undefined
|
|
104
|
-
spawnedChild.once('error', cause => { error = cause })
|
|
105
|
-
spawnedChild.once('close', status => resolve(error ? { status, error } : { status }))
|
|
106
|
-
})
|
|
107
|
-
await spawned
|
|
108
|
-
try { await onSpawn?.() }
|
|
109
|
-
catch (error) { spawnedChild.kill('SIGTERM'); await closed; throw error }
|
|
110
|
-
return await closed
|
|
150
|
+
if (stopSignal) return await endBySignal(stopSignal)
|
|
151
|
+
return result
|
|
111
152
|
} finally {
|
|
112
|
-
|
|
153
|
+
if (giveUp) clearTimeout(giveUp)
|
|
154
|
+
removeHandlers()
|
|
113
155
|
cleanup()
|
|
114
156
|
}
|
|
115
157
|
}
|
|
@@ -4,6 +4,11 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
6
|
import os
|
|
7
|
+
import signal
|
|
8
|
+
import sys
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from contextlib import contextmanager
|
|
7
12
|
from pathlib import Path
|
|
8
13
|
from typing import Any
|
|
9
14
|
from uuid import uuid4
|
|
@@ -431,7 +436,120 @@ def _result_summary(*, run_id: str, best_id: str, best_score: float, seed_score:
|
|
|
431
436
|
}
|
|
432
437
|
|
|
433
438
|
|
|
439
|
+
_STOP_SIGNALS = (signal.SIGINT, signal.SIGTERM, signal.SIGHUP, signal.SIGQUIT)
|
|
440
|
+
# One Ctrl-C reaches the optimizer twice: from the terminal and again from the
|
|
441
|
+
# CLI that passes it on. A repeat this soon after the first is the same press.
|
|
442
|
+
_REPEAT_WINDOW_SECONDS = 1.0
|
|
443
|
+
_STATUS_WRITE_SECONDS = 10.0
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
class RunInterrupted(BaseException):
|
|
447
|
+
"""A stop signal (Ctrl-C, SIGTERM, a closed terminal) reached the run."""
|
|
448
|
+
|
|
449
|
+
def __init__(self, signal_name: str, signum: int) -> None:
|
|
450
|
+
super().__init__(signal_name)
|
|
451
|
+
self.signal_name = signal_name
|
|
452
|
+
self.signum = signum
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _end_by_signal(signum: int) -> None:
|
|
456
|
+
"""Exit the way an unhandled signal would, so callers see 128 + its number."""
|
|
457
|
+
sys.stdout.flush()
|
|
458
|
+
sys.stderr.flush()
|
|
459
|
+
signal.signal(signum, signal.SIG_DFL)
|
|
460
|
+
os.kill(os.getpid(), signum)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
@contextmanager
|
|
464
|
+
def interrupt_signals():
|
|
465
|
+
"""Turn the first stop signal into ``RunInterrupted``; a later one ends at once."""
|
|
466
|
+
first_at: list[float] = []
|
|
467
|
+
|
|
468
|
+
def handle(signum: int, _frame: Any) -> None:
|
|
469
|
+
if not first_at:
|
|
470
|
+
first_at.append(time.monotonic())
|
|
471
|
+
if os.getpgrp() == os.getpid():
|
|
472
|
+
# Runners may run in worker threads, which this interrupt does
|
|
473
|
+
# not reach; when we lead the process group, stop them too.
|
|
474
|
+
# Our own copy of the signal falls in the repeat window.
|
|
475
|
+
try:
|
|
476
|
+
os.killpg(os.getpgrp(), signum)
|
|
477
|
+
except OSError:
|
|
478
|
+
pass
|
|
479
|
+
raise RunInterrupted(signal.Signals(signum).name, signum)
|
|
480
|
+
if time.monotonic() - first_at[0] < _REPEAT_WINDOW_SECONDS:
|
|
481
|
+
return
|
|
482
|
+
_end_by_signal(signum)
|
|
483
|
+
|
|
484
|
+
previous = {signum: signal.getsignal(signum) for signum in _STOP_SIGNALS}
|
|
485
|
+
for signum in _STOP_SIGNALS:
|
|
486
|
+
signal.signal(signum, handle)
|
|
487
|
+
try:
|
|
488
|
+
yield
|
|
489
|
+
finally:
|
|
490
|
+
for signum, handler in previous.items():
|
|
491
|
+
signal.signal(signum, handler)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _stop_when_parent_exits() -> None:
|
|
495
|
+
"""Stop like SIGTERM once the CLI that started us is gone (e.g. SIGKILLed).
|
|
496
|
+
|
|
497
|
+
The CLI puts the optimizer in its own process group, so a signal sent to
|
|
498
|
+
the CLI's group no longer reaches it; without this it would keep running.
|
|
499
|
+
"""
|
|
500
|
+
parent = os.getppid()
|
|
501
|
+
|
|
502
|
+
def watch() -> None:
|
|
503
|
+
while os.getppid() == parent:
|
|
504
|
+
time.sleep(0.5)
|
|
505
|
+
os.kill(os.getpid(), signal.SIGTERM)
|
|
506
|
+
|
|
507
|
+
threading.Thread(target=watch, daemon=True).start()
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _mark_interrupted_run(client: OrizuClient, run_id: str, signal_name: str) -> None:
|
|
511
|
+
"""Mark a stopped local run cancelled once, waiting at most 10 s; never retry."""
|
|
512
|
+
outcome: list[BaseException | None] = []
|
|
513
|
+
|
|
514
|
+
def write() -> None:
|
|
515
|
+
try:
|
|
516
|
+
client.update_run(run_id, status="cancelled",
|
|
517
|
+
metadata={"stop_reason": "interrupted", "signal": signal_name})
|
|
518
|
+
outcome.append(None)
|
|
519
|
+
except BaseException as error: # reported below; the run still stops
|
|
520
|
+
outcome.append(error)
|
|
521
|
+
|
|
522
|
+
writer = threading.Thread(target=write, daemon=True)
|
|
523
|
+
writer.start()
|
|
524
|
+
writer.join(_STATUS_WRITE_SECONDS)
|
|
525
|
+
if not outcome or outcome[0] is not None:
|
|
526
|
+
print(
|
|
527
|
+
f"Run {run_id} may still show as running: run "
|
|
528
|
+
f"`orizu optimizations cancel {run_id}` to mark it stopped.",
|
|
529
|
+
file=sys.stderr, flush=True,
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
|
|
434
533
|
def run_from_environment() -> dict[str, Any]:
|
|
534
|
+
"""Run official GEPA; a stopped local run is marked cancelled (ORI-2402).
|
|
535
|
+
|
|
536
|
+
A hosted run's final status belongs to its coordinator, which records it
|
|
537
|
+
before stopping the sandbox, so a hosted run writes no status here.
|
|
538
|
+
"""
|
|
539
|
+
started: dict[str, Any] = {}
|
|
540
|
+
with interrupt_signals():
|
|
541
|
+
if os.environ.get("ORIZU_STOP_WITH_PARENT") == "1":
|
|
542
|
+
_stop_when_parent_exits()
|
|
543
|
+
try:
|
|
544
|
+
return _run_from_environment(started)
|
|
545
|
+
except RunInterrupted as interrupt:
|
|
546
|
+
if started.get("run_id") and not started.get("hosted"):
|
|
547
|
+
_mark_interrupted_run(started["client"], started["run_id"], interrupt.signal_name)
|
|
548
|
+
_end_by_signal(interrupt.signum)
|
|
549
|
+
raise
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _run_from_environment(started: dict[str, Any]) -> dict[str, Any]:
|
|
435
553
|
"""Run official GEPA over the verified legacy runner subprocess contract.
|
|
436
554
|
|
|
437
555
|
ALI-1502 supplies these variables after verifying runner bytes. Scorer
|
|
@@ -542,6 +660,7 @@ def run_from_environment() -> dict[str, Any]:
|
|
|
542
660
|
model_config_settings_version_id=instruction_set.get("model_config_settings_version_id") if isinstance(instruction_set, dict) else None,
|
|
543
661
|
runner_version_id=prompt_context.runner_version_id,
|
|
544
662
|
)
|
|
663
|
+
started.update(client=client, run_id=run_id, hosted=hosted_run_id is not None)
|
|
545
664
|
print(json.dumps({"optimization_run_id": run_id}), flush=True)
|
|
546
665
|
# The frozen sink treats ``None`` as no local artifact writer. Keep that
|
|
547
666
|
# legacy behavior rather than maintaining a connector-local no-op logger.
|
|
@@ -728,6 +847,9 @@ def run_from_environment() -> dict[str, Any]:
|
|
|
728
847
|
total_tokens_out=int(getattr(reflection_lm, "total_tokens_out", 0)),
|
|
729
848
|
total_tokens=int(getattr(reflection_lm, "total_tokens", 0)),
|
|
730
849
|
)
|
|
850
|
+
# From here the run has its own final status; a stop must not
|
|
851
|
+
# overwrite it with "cancelled".
|
|
852
|
+
started.pop("run_id", None)
|
|
731
853
|
if budget_exhausted:
|
|
732
854
|
if hosted_run_id is None:
|
|
733
855
|
client.update_run(
|
|
@@ -764,6 +886,7 @@ def run_from_environment() -> dict[str, Any]:
|
|
|
764
886
|
temporary_result_path.replace(result_path)
|
|
765
887
|
return summary
|
|
766
888
|
except Exception as error:
|
|
889
|
+
started.pop("run_id", None)
|
|
767
890
|
if sink.failed:
|
|
768
891
|
sink.retry_failed_status()
|
|
769
892
|
elif hosted_run_id is None:
|