feat: report interrupted teardown drains; never subordinate a user abort#1925
feat: report interrupted teardown drains; never subordinate a user abort#1925rasdani wants to merge 29 commits into
Conversation
…patch
An empty --command alongside --script-path previously passed the
exactly-one check (bool("") is falsy) but run_action dispatches on
`command is not None`, so the empty command ran and the script was
silently ignored. Validate on `is None` so validation and execution
agree.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SubprocessRuntime.start() mkdirs /tmp/<name>, so the fixed
debug-{task.idx} name failed on a concurrent session or a stale
workdir left by a failed teardown. Suffix debug and validate
runtime names with a short uuid, matching eval's per-rollout
unique naming.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Resolves verifiers/v1/cli/validate.py against main's setup injection change (#1908): setup is now dispatched via invoke() with task/trace/ runtime available by name. Applied the same convention to the setup call sites this branch added (_run_noop and the debug CLI's debug_task), constructing the trace with the taskset's state class. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Public docs/ are still v0-facing (the v1 migration is tracked in #1916); drop the V1 sections added to docs/evaluation.md and docs/reference.md. The validate/debug CLI docs live in verifiers/v1/GUIDE.md and README.md. The SandboxDebugEnv deprecation note stays: the class warns at runtime either way. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per review: reintroduce once v1 goes public. The validate/debug guidance lives in verifiers/v1/GUIDE.md meanwhile. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per review, no reason for this to be a knob: /tmp is writable in any task image. Traces still record the path under trace.info["debug"]["remote_script_path"]. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replace the flat --setup-timeout/--validate-timeout (validate) and --setup-timeout/--timeout (debug) with a shared CheckTimeoutConfig: --timeout.setup and --timeout.total, mirroring eval's nested --timeout.* TOML shape. Renaming eval's --timeout.rollout to --timeout.total is deferred to a follow-up since that flag has downstream consumers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per review: a debug CLI should save everything the action printed, not a configurable 2000-char tail. trace.info["debug"] keys are renamed stdout_tail/stderr_tail -> stdout/stderr accordingly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per review: future-proofs the aggregate mode against a third high-level validation. The aggregation helpers now take a list of subresult rows instead of exactly two; the nested apply_answer/noop keys are unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per review, keep docs/ untouched by this PR; the runtime DeprecationWarning already points v1 users at the debug CLI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A fixed /tmp path is a shared host file on the subprocess runtime (write resolves absolute paths outside the per-runtime workdir), so concurrent runs with different scripts could execute each other's. Key the path by the run-unique runtime name; /tmp rather than the workdir so the script never pollutes the repo state being inspected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A CancelledError delivered while awaiting runtime.stop() in the finally sailed past 'except Exception' and aborted debug_task before the caller could append_trace — losing exactly the cancelled trace the cancelled-flag path exists to persist. Absorb it there and let the caller re-raise after appending. Composes with #1920, which makes stop() complete teardown and then re-raise the cancellation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The upload is a remote call on prime/modal and ran outside asyncio.wait_for, so a stalled write hung forever with --timeout.total never applying. Upload + execution now share one total budget via run_timed (formerly run_command), rather than each getting their own. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: shield runtime teardown from cancellation framework-wide A Ctrl-C / SIGTERM (converted to KeyboardInterrupt by the CLIs) cancels the owning task, and the cancellation lands inside the finally that awaits runtime.stop(), truncating teardown mid-await and leaking live docker containers or paid Prime sandboxes. The unshielded pattern existed at five sites: rollout.py, both validate modes, debug_task, and the AsyncExitStack callback in mcp/launch.py. Fix it once at the framework level: Runtime.stop() is now a concrete framework method that runs the overridable teardown() (modal/prime move their bodies there) to completion under cancellation via run_shielded, then re-raises the cancellation. A bare asyncio.shield is not enough — it re-raises immediately and the orphaned inner task is cancelled by asyncio.run's shutdown anyway — so the helper keeps awaiting until teardown finishes. All call sites stay unchanged and every future caller is safe by construction. run_shielded also replaces the two inline copies of the pattern (host_endpoint's finally — fixing a corner where a teardown error could swallow a captured cancellation — and append_trace, whose recovery await was itself unshielded against a second cancellation). The sync atexit backstop is unchanged: a second Ctrl-C raises KeyboardInterrupt out of the event loop itself, beyond any task-level shield. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: keep modal's atexit backstop armed through a truncated teardown ModalRuntime.teardown consumed its `_sandbox` guard before the first await, so loop death (second Ctrl-C) mid-terminate left cleanup() keyed off None — the atexit backstop could not terminate the sandbox and it leaked to its max-lifetime. Pre-existing (identical body under the old stop()), with a wider window before the shield; consume the guard only after the terminate attempt, so a truncating CancelledError propagates with the backstop still armed. Document the contract on Runtime.teardown: overrides must not consume state cleanup() keys off before their first await. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: remove teardown test files Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
When Ctrl-C lands while runtime teardowns are in flight, the shielded stop() (#1920) keeps them running — but silently, so the wait reads as a hang and users escalate to kill -9, which skips the atexit backstop entirely. Track in-flight teardowns in a registry and report the drain once, in aggregate (one line for 512 teardowns, not 512): count, oldest age, and that a second Ctrl-C aborts with best-effort cleanup at exit; one closing line when the drain finishes. Per-runtime detail at DEBUG. Also narrow run_shielded's inner catch from BaseException to Exception: a KeyboardInterrupt/SystemExit raised out of the teardown (a signal delivered while the interpreter executes teardown code) must propagate immediately — never be swallowed by the loop or chained under the pending CancelledError — and a force-closed coroutine must not ignore GeneratorExit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ApprovabilityVerdict: Needs human review This PR introduces new feature behavior around interrupt handling during teardown, including a significant change from catching You can customize Macroscope's approvability policy. Learn more. |
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…gold to narrow
Per review: replace --mode {apply-answer, noop, all} with
all-by-default plus two restriction flags. The checks are named for
what they are — gold (setup + validate hook) and setup-only — in
flags, row modes, nested subresult keys, and runtime names.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Cancels the owner while stop() is in flight against a live Prime sandbox and asserts, in-process: the DELETE still completed server-side (TERMINATED, not RUNNING), the drain was reported, and the cancellation propagated afterwards. Verified to fail when the run_shielded guard in Runtime.stop is removed. prime-marked: runs under -m prime locally, excluded from CI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit bc98d9e. Configure here.
… local The prime-marked variant alone never gates PRs (CI excludes real sandboxes), so parametrize the guard test over the runtimes CI does run: subprocess (always) and docker (CI matrix), with prime as the local full-realism variant. The gate holds teardown open so the cancel lands before the real cleanup starts — an unshielded stop() genuinely leaks the workdir/container and fails the leak check. Verified: both CI variants fail with the shield removed and pass with it intact. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Main now contains #1905 (squash-merged), so the drain branch's base content is upstream. Conflicts: pyproject.toml keeps both the debug and new replay console scripts; runtimes/base.py and utils/aio.py take this branch's side (main's versions are the older pre-drain content from the #1905 squash). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 'all in-flight teardowns finished' line fired whenever _STOPPING emptied — including when a second Ctrl-C aborted teardowns mid-drain (this branch makes run_shielded propagate user aborts immediately). Track the abort and report 'teardown drain aborted' instead, so the user who just aborted isn't told everything finished. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
setup_logging(console=False) left loguru with no sink at all when no log_file was given (validate), silently discarding the teardown-drain warnings this branch adds — Ctrl-C still looked like a hang in the default validate UX. Add a WARNING-level sink through the dashboard's own rich Console: an active Live renders prints above the live region cleanly, and eval's rich mode gets on-screen warnings too instead of file-only. Verified under a pty: the warning renders above the frame, no tearing, INFO stays hidden. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Follow-up to #1920 (merged into this base). Two changes, both about what happens when a user interrupts a run whose shielded teardowns are still draining.
1. Report the drain, in aggregate
The shield keeps teardowns running through Ctrl-C — but silently, so the wait reads as a hang; a user whose interrupt "does nothing" escalates to
kill -9, which skips the atexit backstop and guarantees the leak. Nowstop()tracks in-flight teardowns in a registry (_STOPPING) and the first teardown to absorb the interrupt logs one aggregate line; one closing line marks the drain finished. One line for 512 in-flight teardowns, not 512 — dedup via a flag that resets when the registry empties, so a later drain in a long-lived process reports again. Per-runtime detail (name + provider descriptor) logs at DEBUG for--verbose.How the terminal reads
A Ctrl-C'd
validaterun (non-rich mode; WARNING lines are this PR, the rest is existing behavior):The closing
KeyboardInterrupttraceback is the pre-existing exit path (asyncio.runre-raises it), not something this PR adds. Pressing Ctrl-C again mid-drain aborts immediately: the loop dies, the atexit backstop frees what's left silently, then the same traceback. With--verbose, each teardown additionally identifies itself:("freed best-effort at exit" = the sync atexit backstop,
cleanup_at_exit.)Known gap: under the
--richdashboard the report goes to the log file only (console=False— the dashboard owns the screen). Surfacing drain state in the dashboard is its own piece of work.2.
run_shielded: narrowexcept BaseExceptiontoexcept ExceptionRaised by Cursor Bugbot on #1920 (partially valid). The headline mechanism there was off — a signal-handler
KeyboardInterruptnormally lands in the event loop's frame (a suspended coroutine isn't executing) and kills the loop as designed — but the broad catch was still wrong in three narrow windows: a signal delivered while the interpreter executesrun_shielded's own frame was swallowed (the abort silently ignored); aKeyboardInterrupt/SystemExitraised out of the teardown itself got chained under the pendingCancelledErrorinstead of winning; and a force-closed coroutine would swallowGeneratorExitonce and re-await ("coroutine ignored GeneratorExit"). The catch's only job — defer the inner task's failure so it unifies with the cancellation at the bottom — needsExceptiononly. A user abort now always propagates immediately.Verification
-m "not prime and not modal"passes.test_cancelled_stop_still_frees_runtime— the e2e trip-wire for the guard, parametrized over runtimes so it actually gates PRs: subprocess (always in CI), docker (CI matrix), prime (local-only, real API DELETE observed server-side). A gate holds teardown open so the cancel deterministically lands mid-stop(); asserts teardown ran to completion, the resource is really gone (workdir / container / sandbox TERMINATED), the drain was reported, and the cancellation propagated. Verified to fail on all CI variants when therun_shieldedguard inRuntime.stopis removed.🤖 Generated with Claude Code
Note
Medium Risk
Changes shutdown and cancellation behavior on the path that frees containers and paid sandboxes; fixes are guarded by new e2e leak checks but alter how second Ctrl-C and BaseException interact with shielded work.
Overview
When Ctrl-C hits while shielded runtime teardown is still running,
Runtime.stopnow tracks in-flight stops and logs one aggregated warning (count, oldest age, hint to Ctrl-C again) on the first absorbed cancellation, then a single closing line when the drain finishes or is aborted. Per-runtime detail stays at DEBUG.run_shieldedgains an optionalinterruptedcallback (used bystop()for that report) and no longer swallowsKeyboardInterrupt/SystemExit: user aborts propagate immediately instead of being chained under a pendingCancelledError.With
console=False(rich dashboard), WARNING logs still print via Rich’s console so drain messages don’t look like a silent hang.A new parametrized e2e test (subprocess / docker / prime) cancels mid-
stop(), asserts teardown completes, resources are freed, drain text is logged, and cancellation still propagates.Reviewed by Cursor Bugbot for commit b2a917e. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Report interrupted teardown drains and immediately propagate user aborts in
Runtime.stopRuntime.stopnow tracks in-flight teardowns in a module-level_STOPPINGregistry and, on first absorbed cancellation, logs a single aggregated warning with the in-flight count and oldest teardown age.run_shieldedin aio.py accepts an optionalinterruptedcallback invoked on the first absorbedCancelledError;BaseException(e.g.KeyboardInterrupt,SystemExit) now propagates immediately instead of being swallowed.console=False, WARNING-level logs are now emitted via the active rich Console rather than suppressed, so warnings surface above a Live region.test_cancelled_stop_still_frees_runtimevalidates that teardown completes and resources are freed even when cancellation arrives duringstop(), and that the aggregated warning is logged.KeyboardInterrupt/SystemExitduring a shielded teardown now re-raises immediately; previously these were caught and suppressed byrun_shielded.Macroscope summarized b2a917e.