feat: make automation sandbox cleanup delay configurable - #459
Conversation
Automation conversations showed up as archived and read-only the moment a run finished. `complete_run` and both watchdog terminal paths delete the run's sandbox immediately unless the automation's `keep_alive` is true, and a conversation whose sandbox is gone renders as archived with no way to resume it. So when a run failed, the conversation that would explain it was already unreachable. `keep_alive` was the only escape, and it is API-only. Add `AUTOMATION_SANDBOX_CLEANUP_DELAY_SECONDS`, defaulting to 0, which keeps the immediate delete. When it is set, the terminal transition stamps `automation_runs.sandbox_cleanup_due_at` in the same UPDATE as the status change and pauses the sandbox instead of deleting it, so the conversation stays resumable. Pausing rather than leaving it running matters: a paused sandbox does not count toward the per-user running-sandbox cap, which is the pressure the immediate delete existed to relieve. A new sweep in the watchdog janitor loop deletes sandboxes once their stamp is due and clears it after one attempt, matching the best-effort semantics of the immediate path; the runtime's TTL reaper remains the backstop, so a sandbox that will not delete cannot pin the sweep. The delay is measured from run completion, not last activity. Cancelling a run still deletes at once, and keep_alive=true automations, which includes every continue_conversation trigger, are never stamped or paused. Migration 024 adds the column with a partial index on both PostgreSQL and SQLite. The matching Replicated installer option ships from the OpenHands-Cloud release. Refs OHE-2833
|
Warning Your comment is too long (maximum is 65536 characters), so the coverage report was not added. See the job log for how to reduce it. |
|
👋 This PR needs a couple of things fixed before OpenHands can review it:
Push an update once this is addressed and this check re-runs automatically. This is an automated check - no AI was used to generate this comment. |
There was a problem hiding this comment.
Review superseded — please see the full review below.
This placeholder was created by an AI agent (OpenHands) on behalf of the reviewer due to a tooling mishap posting the file path instead of its contents. The actual review content is in the subsequent comment.
tofarr
left a comment
There was a problem hiding this comment.
Code Review: PR #459 — feat: make automation sandbox cleanup delay configurable
Taste Rating: 🟢 Good taste
This is a well-designed change. The core insight — pause the sandbox rather than keep it alive, so finished automations don't consume running-sandbox slots while remaining resumable — is exactly the kind of data-structure/behavior choice that eliminates special cases. Stamping sandbox_cleanup_due_at inside the same optimistic-lock UPDATE as the terminal status transition, defaulting to 0 (zero behavior change), partial-indexing the NULL-heavy column, and matching the best-effort semantics of the existing immediate-cleanup path are all the right calls. The migration chain is clean (023 → 024, single head), tests assert on real DB state rather than just mocked calls, and CI is green.
No critical issues. A few observations below, none blocking.
[IMPROVEMENT OPPORTUNITIES]
-
openhands/automation/config.py/router.py/watchdog.py— local-mode inconsistency (minor). The setting is documented as "cloud mode only," and thecleanup_due_sandboxessweep is correctly gated behindif not settings.is_local_mode. But the stamping + pausing incomplete_runand_verify_and_mark_runis not gated to cloud mode. If a local-mode operator setsAUTOMATION_SANDBOX_CLEANUP_DELAY_SECONDS > 0, runs get stamped andpause_sandboxis called against the local agent server (which may not implement/pause), the pause fails silently, and — because the sweep never runs locally — thesandbox_cleanup_due_atstamp is never cleared. It's harmless (lingering timestamps, localcleanup_after_verificationis a no-op), but it's an inconsistency between the documented contract and the behavior. Consider either gating the stamp/pause onnot settings.is_local_modeor noting that local mode ignores the value. Not blocking — the docstring already signals cloud-only intent. -
openhands/automation/watchdog.py,_verify_and_mark_run(both terminal sites) — in-transaction HTTP call._defer_sandbox_cleanupissuespause_sandbox(a network round-trip with a 30s timeout) inside the DB session, beforemark_stale_runscommits. This holds the transaction open across the pause call. This is consistent with the pre-existing pattern — the immediatebackend.cleanup_after_verification(run_id)is also called inside the session — so it's not a regression. Worth being aware of if delays creep in, but no action needed for this PR. -
openhands/automation/watchdog.py,_verify_and_mark_run— duplicated branch. Theif settings.sandbox_cleanup_delay_seconds > 0: _defer... else: cleanup...block appears at both terminal sites (verified-exit and unverifiable-timeout). It's 4 lines each, so a helper would barely save anything, but if a third terminal path ever appears it'd be worth extracting. Acceptable as-is.
[STYLE NOTES]
- Skipped. Comments are informative and explain why (pause-vs-keep-alive rationale, partial-index justification, reconcile semantics), not what. No noise.
[TESTING GAPS]
- None material. Tests cover the deferred path (pause + stamp),
keep_alive=trueoverride, both watchdog terminal sites, sweep only-touches-due-rows, stamp-cleared-on-failure, andpause_sandbox200/404/transport-failure. They assert on real DB state (sandbox_cleanup_due_at,completed_at, status) rather than only mocked calls. The router reconcile path (watchdog-marks-FAILED-then-callback-flips-to-COMPLETED with a delay configured) isn't explicitly tested for the re-stamp, but it's covered by the existing reconcile tests + thevaluesdict reuse, and the behavior is defensible.
[RISK ASSESSMENT]
- [Overall PR]
⚠️ Risk Assessment: 🟢 LOW- Default
0preserves today's behavior exactly — no existing deployment changes unless an operator opts in. - The new path only activates when
sandbox_cleanup_delay_seconds > 0andkeep_alive is not True, both explicitly checked. - Best-effort semantics match the immediate-cleanup path; the runtime TTL reaper remains the backstop, so a sandbox that won't pause or delete cannot pin the sweep or leak forever.
- Migration is additive (nullable column + partial index) with a clean downgrade.
- CI green (unit-tests, backend, build all pass).
- Default
VERDICT: ✅ Worth merging. Core logic is sound, well-tested, and the design choice (pause over keep-alive) is the right one.
KEY INSIGHT: Stamping the due timestamp inside the optimistic-lock terminal UPDATE — rather than as a separate step — is what makes this race-safe against the watchdog/callback concurrency that already exists in this codebase.
This review was generated by an AI agent (OpenHands) on behalf of the reviewer.
|
🚀 Released in 1.12.0. |
Summary
Automation-generated conversations are archived the instant a run finishes, and there is no way to get back into one.
complete_runand both watchdog terminal paths delete the run's sandbox immediately unless the automation'skeep_aliveis true, and a conversation whose sandbox is gone renders as archived and read-only with no resume path. So when an automation fails or does something unexpected, the conversation that would explain it is already gone.This adds a deployment-level delay. With
AUTOMATION_SANDBOX_CLEANUP_DELAY_SECONDSset, a finished run's sandbox is paused rather than deleted, and the watchdog deletes it once the delay has passed. The conversation stays open and resumable for that window. The default is0, which keeps today's behaviour exactly.Pausing rather than simply keeping the sandbox alive is deliberate: paused sandboxes do not count toward the per-user running-sandbox cap, so a finished automation can no longer cost a user an interactive conversation slot, which is the reason the immediate delete was there in the first place.
Linear: OHE-2833
Changes
config.py—ServiceSettings.sandbox_cleanup_delay_seconds(AUTOMATION_SANDBOX_CLEANUP_DELAY_SECONDS,int >= 0, default0), documented in the settings docstring next to the other retention knobs.models.py+ migration024— nullableautomation_runs.sandbox_cleanup_due_at, with a partial index (WHERE sandbox_cleanup_due_at IS NOT NULL) on both PostgreSQL and SQLite, since the column is NULL on nearly every row.utils/sandbox.py—pause_sandbox, a best-effortPOST /api/v1/sandboxes/{id}/pausebuilt on the same shape as the existingcleanup_sandbox.router.py/complete_run— when a delay is configured, the due timestamp is written inside the same optimistic-lock UPDATE asstatusandcompleted_at, so it cannot be lost, and it is re-applied on the watchdog-timeout reconcile path. The fire-and-forget task then pauses instead of deleting.cancel_runis untouched and still deletes immediately, since cancelling is explicit.watchdog.py— both terminal sites defer through a shared_defer_sandbox_cleanup, and a newcleanup_due_sandboxessweep runs in the existing janitor loop (cloud mode only), deleting due sandboxes in bounded batches through the run's own backend. It clears the stamp after one attempt whatever the outcome, matching the best-effort semantics of the immediate path; the runtime's TTL reaper remains the backstop, so a sandbox that refuses to delete cannot pin the sweep.AGENTS.md— the sandbox-cleanup bullet now describes both paths.Behaviour
0(default)> 0sandbox_cleanup_due_at = completed_at + delayA few things worth knowing:
keep_alive=trueautomations, which includes everycontinue_conversationtrigger, are never stamped or paused; that path is unchanged.Notes for reviewers
024rather than023because the org-scoped git sync work (feat: scope git sync to organizations so cloud deployments can sync #429) landed023on main in the meantime.alembic headsreports a single head.cleanup_atcolumn, and no pause step. This PR goes with seconds and a default of0, so existing deployments keep their current behaviour until an operator opts in, and pauses the sandbox so the delay does not consume sandbox slots.Testing
Run against current
main(which now includes the org-scoped git sync change):--cov=openhands/automation --cov-fail-under=76): 1750 passed, 7 skipped, coverage 76.10%, floor met.pre-commiton every touched file — ruff format, ruff lint, pycodestyle, pyright: all pass.alembic headsreports a single head (024). Against SQLite:upgrade head→downgrade 023→upgrade headis clean, leaving the column and the partial index in place.New tests cover the behaviour this adds:
tests/test_router.py— the deferred path pauses and stamps the due time;keep_alive=truestill wins over a configured delay.tests/test_watchdog.py— both terminal sites defer instead of deleting, the sweep touches only rows that are actually due, the stamp is cleared even when deletion fails, and the sweep joins the janitor cycle in cloud mode only.tests/test_sandbox.py—pause_sandboxagainst a 200, a 404, and a transport failure.