Skip to content

[eval] fix: make a second Ctrl-C exit without the interrupted-shutdown grace - #444

Merged
JoyboyBrian merged 4 commits into
Osmosis-AI:mainfrom
TanishGudise:fix/eval-second-interrupt-exits-now
Oct 12, 2026
Merged

JoyboyBrian merged 4 commits into
Osmosis-AI:mainfrom
TanishGudise:fix/eval-second-interrupt-exits-now

Conversation

@TanishGudise

@TanishGudise TanishGudise commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

What

A second Ctrl-C during osmosis eval run now skips the interrupted-shutdown waits in both windows where it can land:

  • During workflow cleanup (dispatch is still settling cancelled rollouts): the rollout server is stopped with the ordinary short grace (_SERVER_TERM_GRACE_SEC, 5 s) instead of the interrupted-shutdown grace (330 s).
  • During server termination (the up-to-330 s wait after cleanup): the second Ctrl-C now reaches the supervisor. It terminates the server's process group again with the ordinary 5 s grace, then SIGKILL, which ends the 330 s wait. Shutdown still stops the tunnel and listener before exiting.
  • A third Ctrl-C exits immediately, even mid-shutdown, and may skip tunnel and listener cleanup (test_a_third_interrupt_exits_even_while_shutdown_is_under_way).

Why

_request_cancel handles the second interrupt by printing "exiting now" and raising KeyboardInterrupt. Two gaps let the long grace survive it:

  1. asyncio.run still cancels and finishes the supervisor task on the way out, so _stop_rollout_server saw a cancelled task and _cancelled set, and waited up to 330 s for the server. The handler now sets self._force_exit = True before raising, and _stop_rollout_server uses the short grace when it is set.
  2. _schedule() removed its SIGINT/SIGTERM handlers before _stop_rollout_server() ran. A second Ctrl-C during the 330 s termination therefore went to Python's default handler: it never reached _request_cancel, and asyncio.run still waited for the termination thread to run out the grace (reported by cubic). After an interrupted dispatch the handlers now stay installed until _execute finishes shutting down. A second interrupt there runs terminate_process_group again with the ordinary grace, and the first wait returns as soon as the group is gone. It does not raise mid-shutdown, because that would skip _stop_tunnel() and leave a cloudflared quick tunnel running; _execute raises KeyboardInterrupt once shutdown completes, so both windows exit the same way.

A single Ctrl-C is unchanged: it still allows the 120 s settle and the 330 s server grace so workflow and sandbox cleanup can finish. A run that finishes or halts without a Ctrl-C keeps the old handler lifetime.

Trade-off: for a Harbor/Docker rollout, a server killed after the short grace may not finish its sandbox cleanup, so containers can be left behind until the orphan reaper cleans them up on the next run. That is what an explicit second Ctrl-C asks for, and a single Ctrl-C remains the clean path.

docs/eval-run-local.md said "A second Ctrl-C exits immediately". It now says what happens: the remaining waits are skipped, the server gets the ordinary five-second grace before it is force-stopped, and the tunnel and listener are still stopped.

Related: #443, #445

How to Test

  • uv run pytest tests/unit/eval/local/test_runner_e2e.py -k second_interrupt runs two synchronous tests. Each runs a real supervisor, rollout server and stub model inside asyncio.run and sends real SIGINTs. Before each signal they check that the supervisor's handler owns SIGINT, so a failure can't interrupt pytest itself.
    • test_a_second_interrupt_stops_the_server_with_the_ordinary_grace: the second SIGINT arrives while the workflow is still cleaning up. On main it fails with [330.0] == [5.0].
    • test_a_second_interrupt_during_server_shutdown_cuts_its_grace_short: the second SIGINT arrives while the server is still draining under the 330 s grace. It checks that the server is terminated again with the 5 s grace, that the long wait ends because the server stopped, and that the server's ownership record is cleared. On this PR's first commit (1c8ab86) it fails with no interrupt handler during server shutdown.
    • Both pass 10/10 runs on Python 3.12. tests/unit/eval/local also passes 3/3 runs each on 3.13 and 3.14.
  • Existing single-interrupt coverage still passes: test_cancel_settles_cleanup_before_stopping_the_server and test_server_shutdown_only_waits_for_cleanup_when_interrupted.
  • uv run pytest -n 4 --dist worksteal: 3679 passed, 1 skipped. On main: 3676 passed, 1 skipped.

Checklist

  • PR title follows [module] type: description format
    (labels are derived from it automatically — no need to add them by hand)
  • ruff check . and ruff format --check . pass
  • ty check --error-on-warning osmosis_ai/ passes; uv run --no-editable pyright --verifytypes osmosis_ai --ignoreexternal has no unexpected errors (see CONTRIBUTING.md for the Harbor baseline)
  • pytest passes (new tests added if applicable)
  • Public API changes are documented
  • No secrets or credentials included

Pyright reports only the documented Harbor baseline errors. git diff --check is clean. This PR has no public API changes. docs/eval-run-local.md is updated to match the new behavior.


Summary by cubic

Makes Ctrl-C handling during osmosis eval run match the documented immediate-exit behavior: a second interrupt exits without the interrupted-shutdown grace (330 s), and a third exits immediately even mid-shutdown.

  • The first Ctrl-C is unchanged and still allows the 120 s settle and 330 s server grace for workflow and sandbox cleanup.
  • A second interrupt sets _force_exit: during dispatch it raises at once, and during server shutdown it re-terminates the server's process group with the ordinary 5 s grace and lets shutdown finish stopping the tunnel and listener.
  • SIGINT handlers now stay installed during shutdown so a second interrupt reaches the supervisor, and _stop_rollout_server uses the short grace when _force_exit is set.
  • Trade-off: a server killed after the short grace may not finish sandbox cleanup, leaving containers for the orphan reaper on the next run. docs/eval-run-local.md is updated to describe the three-step exit behavior.

Written for commit c3012c4. Summary will update on new commits.

View guided diff

…n grace

A second Ctrl-C prints "exiting now" and raises KeyboardInterrupt, but
asyncio.run still cancels and finishes the supervisor task on the way
out. _stop_rollout_server then saw a cancelled task and gave the rollout
server the full interrupted-shutdown grace (330s), contradicting the
documented "A second Ctrl-C exits immediately".

Record the forced exit before raising and use the ordinary short grace
for that shutdown. A single Ctrl-C keeps the long grace so workflow and
sandbox cleanup can finish.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 2 files

Reply with feedback, questions, or to request a fix.

View guided diff | Re-trigger cubic

Comment thread osmosis_ai/eval/local/runner.py Resolved
TanishGudise and others added 2 commits October 9, 2026 18:11
…ce short

The second-interrupt handler was removed when dispatch ended, so a second
Ctrl-C during the rollout server's interrupted shutdown (up to 330s) went to
Python's default handler: it never reached the supervisor, and asyncio.run
still waited for the terminate thread to run out the long grace.

After an interrupted dispatch, keep the handler installed until shutdown
completes. A second interrupt during shutdown re-terminates the server's
process group with the ordinary grace (then SIGKILL), which ends the long
wait, and lets shutdown finish stopping the tunnel and listener before
exiting with KeyboardInterrupt. A second interrupt during dispatch still
raises at once.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
A second interrupt during shutdown lets cleanup finish (with the server's
grace cut short), so a further interrupt was ignored. Raise on the third
one instead: the user is asking to exit now, even if that skips tunnel and
listener cleanup. Document the three steps in eval-run-local.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
JoyboyBrian added a commit that referenced this pull request Oct 12, 2026
…445)

## What

The live progress bar's pass rate now excludes skipped samples from its
denominator, the same way `aggregate_metrics` does, so it matches
`metrics.json`.

## Why

`ProgressSnapshot.pass_rate` divided passes by `completed`, which counts
skipped samples (for example, `remove_sample`). `aggregate_metrics`
divides by non-skipped samples only. A run with 2 passes out of 2 graded
rows and 2 skipped rows showed 25% on the progress bar but reported 50%
at the end. `ProgressSnapshot` gains a `skipped` field, defaulting to 0
so existing callers keep working. `_report_progress` fills it in, and
`pass_rate` divides by `completed - skipped`.

Related: #443, #444

## How to Test

- `uv run pytest tests/unit/eval/local/test_runner_units.py -k
live_pass_rate`
- The new test drives `_report_progress` with two graded and two skipped
records and compares the snapshot's pass rate with `aggregate_metrics`
over the same rows.
  - On `main` it fails with `0.25 == 0.5`; with the fix it passes.
- `uv run pytest -n 4 --dist worksteal`: 3677 passed, 1 skipped. On
`main`: 3676 passed, 1 skipped.

## Checklist

- [x] PR title follows `[module] type: description` format
(labels are derived from it automatically — no need to add them by hand)
- [x] `ruff check .` and `ruff format --check .` pass
- [x] `ty check --error-on-warning osmosis_ai/` passes; `uv run
--no-editable pyright --verifytypes osmosis_ai --ignoreexternal` has no
unexpected errors (see `CONTRIBUTING.md` for the Harbor baseline)
- [x] `pytest` passes (new tests added if applicable)
- [x] Public API changes are documented
- [x] No secrets or credentials included

`ProgressSnapshot` gains an optional `skipped` field with a default of
0, which is backward-compatible. Pyright's type completeness is still
100%, with only the Harbor baseline errors.

<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Fixes the live eval progress bar pass rate so it excludes skipped
samples and agrees with `metrics.json`, which the bar previously
disagreed with whenever a run contained skipped rows. `ProgressSnapshot`
now carries a `skipped` count, defaulting to 0 so existing callers keep
working, and `pass_rate` divides passes by completed minus skipped. Adds
a regression test driving `_report_progress` over graded and skipped
rows and comparing the snapshot rate with `aggregate_metrics`.

<sup>Written for commit a3a916c.
Summary will update on new commits.</sup>

<a
href="https://cubic.dev/pr/Osmosis-AI/osmosis-sdk-python/pull/445?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="View guided diff"
src="https://www.cubic.dev/buttons/review-in-cubic-light.svg"></picture></a>

<!-- End of auto-generated description by cubic. -->

Co-authored-by: Tanish Gudise <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brian <[email protected]>
@JoyboyBrian
JoyboyBrian merged commit de7b298 into Osmosis-AI:main Oct 12, 2026
21 checks passed
JoyboyBrian added a commit that referenced this pull request Oct 12, 2026
…a sample (#443)

## What

The local eval worker now waits for a rollout's trajectory only when the
rollout produced a sample. Rollouts that end without one (workflow
exception, timeout, lease expiry) release their worker slot as soon as
the result is committed.

## Why

After `_commit_outcome`, every worker called `_await_trajectory`, which
polls for `trials/<rollout_id>/trajectory.json` for up to
`_TRAJECTORY_GRACE_SEC` (30 s). A rollout without a sample never writes
a trajectory: `save_trajectory` returns early when `sample is None`. So
every such failure held its worker for the full 30 s, and a run with
many failures slowed to one failure per slot every 30 s.

Skipping the wait loses nothing. The rollout server saves the trajectory
inside `_handle_rollout` before it calls `registry.complete()`, so any
trajectory is already on disk by the time the runner sees the result.
Every `_refresh_snapshots` call re-reads trajectories from disk through
`select_attempts`, including the full refresh in `_finalize`. That also
covers the case where a grader fails after a successful workflow: the
wire result has no sample, but `result_to_save` saves the workflow's
sample, and that trajectory is already written.

Related: #444, #445

## How to Test

- `uv run pytest tests/unit/eval/local/test_runner_e2e.py -k
without_a_sample`
- The new test makes the workflow raise and records every trajectory
poll the runner makes, so it checks whether the poll runs at all, not
how long it takes.
  - On `main` it fails: 4 failed rollouts and 12 polls.
  - With the fix: 4 failed rollouts and no polls.
- Timing with the real 30 s grace: 34.9 s on `main` vs 4.5 s with the
fix.
- `uv run pytest -n 4 --dist worksteal`: 3677 passed, 1 skipped. On
`main`: 3676 passed, 1 skipped.

## Checklist

- [x] PR title follows `[module] type: description` format
(labels are derived from it automatically — no need to add them by hand)
- [x] `ruff check .` and `ruff format --check .` pass
- [x] `ty check --error-on-warning osmosis_ai/` passes; `uv run
--no-editable pyright --verifytypes osmosis_ai --ignoreexternal` has no
unexpected errors (see `CONTRIBUTING.md` for the Harbor baseline)
- [x] `pytest` passes (new tests added if applicable)
- [x] Public API changes are documented
- [x] No secrets or credentials included

Pyright `--verifytypes` reports only the documented
`harbor.harness_agent` errors, with a type completeness of 100%. `git
diff --check` is clean. This PR has no public API changes.

---------

Co-authored-by: Tanish Gudise <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Brian <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working eval Eval/Rubric evaluation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants