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.
Files changed (50) hide show
  1. package/dist/hosted-agent-prompt.d.ts +6 -0
  2. package/dist/hosted-agent-prompt.d.ts.map +1 -1
  3. package/dist/hosted-agent-prompt.js +11 -0
  4. package/dist/hosted-boot.d.ts +3 -0
  5. package/dist/hosted-boot.d.ts.map +1 -1
  6. package/dist/hosted-boot.js +14 -0
  7. package/dist/hosted-bootstrap-rehearsal.d.ts +2 -0
  8. package/dist/hosted-bootstrap-rehearsal.d.ts.map +1 -1
  9. package/dist/hosted-bootstrap-rehearsal.js +5 -0
  10. package/dist/hosted-broker-server.d.ts +2 -0
  11. package/dist/hosted-broker-server.d.ts.map +1 -1
  12. package/dist/hosted-broker-server.js +4 -1
  13. package/dist/hosted-harness-opencode.d.ts +2 -0
  14. package/dist/hosted-harness-opencode.d.ts.map +1 -1
  15. package/dist/hosted-harness-opencode.js +2 -2
  16. package/dist/hosted-loop-lifecycle.d.ts +2 -0
  17. package/dist/hosted-loop-lifecycle.d.ts.map +1 -1
  18. package/dist/hosted-loop.d.ts.map +1 -1
  19. package/dist/hosted-loop.js +3 -0
  20. package/dist/hosted-project-folder-seed.d.ts +18 -0
  21. package/dist/hosted-project-folder-seed.d.ts.map +1 -0
  22. package/dist/hosted-project-folder-seed.js +78 -0
  23. package/dist/hosted-session-cli.d.ts.map +1 -1
  24. package/dist/hosted-session-cli.js +2 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +8 -8
  27. package/dist/score-save-workflow.d.ts +10 -14
  28. package/dist/score-save-workflow.d.ts.map +1 -1
  29. package/dist/score-save-workflow.js +14 -11
  30. package/dist/secret-redaction.d.ts +14 -0
  31. package/dist/secret-redaction.d.ts.map +1 -1
  32. package/dist/secret-redaction.js +25 -0
  33. package/dist/skilled-proposer-launch.d.ts +5 -0
  34. package/dist/skilled-proposer-launch.d.ts.map +1 -1
  35. package/dist/skilled-proposer-launch.js +104 -46
  36. package/package.json +1 -1
  37. package/src/hosted-agent-prompt.ts +11 -0
  38. package/src/hosted-boot.ts +18 -0
  39. package/src/hosted-bootstrap-rehearsal.ts +6 -0
  40. package/src/hosted-broker-server.ts +5 -1
  41. package/src/hosted-harness-opencode.ts +4 -2
  42. package/src/hosted-loop-lifecycle.ts +2 -0
  43. package/src/hosted-loop.ts +2 -0
  44. package/src/hosted-project-folder-seed.ts +95 -0
  45. package/src/hosted-session-cli.ts +3 -1
  46. package/src/index.ts +9 -11
  47. package/src/score-save-workflow.ts +21 -16
  48. package/src/secret-redaction.ts +30 -0
  49. package/src/skilled-proposer-launch.ts +78 -36
  50. 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 response = await authedFetch(`/api/cli/runs/submit?project=${encodeURIComponent(project)}`, {
800
- method: 'POST',
801
- headers: { 'Content-Type': 'application/json' },
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
- }>(response, 'Run submit')
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
- const SavedReply = Schema.Struct({ scoreRun: Schema.Struct({ id: Schema.NonEmptyString }) })
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 scoreMayBeSaved = (save: ScoreSave) =>
38
- `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.`
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 === SERVER_MAY_BE_SAVED) throw new ScoreSaveFailure(appError, true)
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(SavedReply)(data))
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 scorers exec`: the reply, or an error saying whether the score may be saved. */
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${scoreMayBeSaved(save)}` : failure.message)),
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)
@@ -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
- const signals = ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGQUIT'] as const
66
- const handlers = signals.map(signal => ({
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
- try { cleanup() }
70
- finally {
71
- try { child?.kill(signal) }
72
- finally {
73
- for (const entry of handlers) process.off(entry.signal, entry.handle)
74
- process.kill(process.pid, signal)
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.once(entry.signal, entry.handle)
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
- return await runPipedChild(
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
- const spawnedChild = spawn(command, args, {
94
- stdio: 'inherit',
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
- for (const entry of handlers) process.off(entry.signal, entry.handle)
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: