Repository navigation
[eval] fix: make a second Ctrl-C exit without the interrupted-shutdown grace - #444
Merged
JoyboyBrian merged 4 commits intoOct 12, 2026
Conversation
…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]>
This was referenced Oct 9, 2026
Contributor
There was a problem hiding this comment.
All reported issues were addressed across 2 files
Reply with feedback, questions, or to request a fix.
View guided diff | Re-trigger cubic
…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
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]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
A second Ctrl-C during
osmosis eval runnow skips the interrupted-shutdown waits in both windows where it can land:_SERVER_TERM_GRACE_SEC, 5 s) instead of the interrupted-shutdown grace (330 s).test_a_third_interrupt_exits_even_while_shutdown_is_under_way).Why
_request_cancelhandles the second interrupt by printing "exiting now" and raisingKeyboardInterrupt. Two gaps let the long grace survive it:asyncio.runstill cancels and finishes the supervisor task on the way out, so_stop_rollout_serversaw a cancelled task and_cancelledset, and waited up to 330 s for the server. The handler now setsself._force_exit = Truebefore raising, and_stop_rollout_serveruses the short grace when it is set._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, andasyncio.runstill waited for the termination thread to run out the grace (reported by cubic). After an interrupted dispatch the handlers now stay installed until_executefinishes shutting down. A second interrupt there runsterminate_process_groupagain 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;_executeraisesKeyboardInterruptonce 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.mdsaid "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_interruptruns two synchronous tests. Each runs a real supervisor, rollout server and stub model insideasyncio.runand 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. Onmainit 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 withno interrupt handler during server shutdown.tests/unit/eval/localalso passes 3/3 runs each on 3.13 and 3.14.test_cancel_settles_cleanup_before_stopping_the_serverandtest_server_shutdown_only_waits_for_cleanup_when_interrupted.uv run pytest -n 4 --dist worksteal: 3679 passed, 1 skipped. Onmain: 3676 passed, 1 skipped.Checklist
[module] type: descriptionformat(labels are derived from it automatically — no need to add them by hand)
ruff check .andruff format --check .passty check --error-on-warning osmosis_ai/passes;uv run --no-editable pyright --verifytypes osmosis_ai --ignoreexternalhas no unexpected errors (seeCONTRIBUTING.mdfor the Harbor baseline)pytestpasses (new tests added if applicable)Pyright reports only the documented Harbor baseline errors.
git diff --checkis clean. This PR has no public API changes.docs/eval-run-local.mdis updated to match the new behavior.Summary by cubic
Makes Ctrl-C handling during
osmosis eval runmatch the documented immediate-exit behavior: a second interrupt exits without the interrupted-shutdown grace (330 s), and a third exits immediately even mid-shutdown._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._stop_rollout_serveruses the short grace when_force_exitis set.docs/eval-run-local.mdis updated to describe the three-step exit behavior.Written for commit c3012c4. Summary will update on new commits.