ENG-822 - Sandbox provisioning failures are transient but unclassified — the in-run retry never covers them - #282
Conversation
Route transient drukbox control-plane failures through the existing bounded in-run retry so slow provider windows recover instead of dead-ending a run. - Add HarnessSandboxProvisioningError (code="sandbox_provisioning"), inheriting HarnessSandboxError's transient retry schedule. - Translate SandboxProvisioningError/SandboxUnavailableError from create_host and SandboxUnavailableError from get_host at the shared client boundary, preserving the SDK exception as the cause; fatal SDK errors pass through. - Reclassify acquire-time SSH/helper reachability failures as provisioning after the existing rollback; cancellation and local/programming errors propagate unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Verification — PASS (round 1)
Diff: backend/druks/harnesses/exceptions.py (+13), backend/druks/sandbox/client.py (+54/-11), plus test-only changes to backend/tests/test_agents.py (+180), backend/tests/test_run_state.py (+19), backend/tests/test_sandbox_lifecycle.py (+133/-1). No schema, migration, API, frontend, or dependency/lockfile changes — matches the plan's declared scope.
AC1 — PASS. HarnessSandboxProvisioningError(HarnessSandboxError) added at harnesses/exceptions.py with code="sandbox_provisioning", inheriting the transient retry/retry_delays without redeclaring them. Client.acquire translates create_host's SandboxProvisioningError/SandboxUnavailableError; Client.attach translates get_host's SandboxUnavailableError. Both preserve the SDK exception via raise ... from exc. Covered by test_acquire_translates_transient_create_failures and test_attach_translates_unavailable_lookup_failure, asserting type, code, retry metadata, and __cause__.
AC2 — PASS. client.py's acquire rollback (aclose, _best_effort_delete, key unlink) runs unconditionally in except BaseException, then reclassifies only if the error is in _ACQUIRE_SETUP_REACHABILITY_ERRORS (SandboxError, asyncssh.Error, OSError, TimeoutError) — CancelledError re-raises unchanged. Covered by test_acquire_classifies_setup_reachability_failure_after_rollback and test_acquire_setup_cancellation_propagates_unclassified.
AC3 — PASS. agents.py/workflows.py are unchanged, so the new subtype flows through the existing bounded transient retry unmodified. Idempotency keys are stable by construction (keyed on workflow/step identity, not attempt count) for both ephemeral and workflow-reused acquisition, verified across durable-step and in-step retry paths.
AC4 — PASS. Exhausted retries propagate code == "sandbox_provisioning", and _execute_run persists Run.failure_code == "sandbox_provisioning" (verified in test_run_state.py), replacing the previous empty-string dead-run symptom.
AC5 — PASS. Fatal SandboxAPIError subclasses (auth, validation, conflict, etc.) are untouched — only the two transient shapes are caught — so fatal errors propagate after a single attempt with no retry sleep.
Checks: uv run ruff check backend pass · uv run ruff format --check backend pass · uv run pytest backend/ pass (1254 passed) · Backend CI (On Pull Request Backend / checks) green for 777d1894. Frontend lint/build/test checks correctly did not trigger — the PR touches no frontend/** files and those workflows are path-filtered.
No blocking findings.
Open findings (non-blocking)
- Low —
_ACQUIRE_SETUP_REACHABILITY_ERRORSinclient.pyincludesasyncssh.Error,OSError, andTimeoutErroralongsideSandboxError, but new tests only exercise theSandboxErrorandCancelledErrorbranches; the other three types are untested. Low risk given the genericisinstancecheck, but worth a follow-up test if convenient. - Low — pre-existing, out of this diff's scope: the rollback calls inside the
except BaseExceptionblock inclient.pyaren't themselves guarded, so a raise during rollback could mask the original error before reclassification. This shape predates the PR and isn't part of the plan's stated scope.
Code review
No medium or high findings. The clean-room lens read the full diff and all five touched files end to end:
HarnessSandboxProvisioningErrorfollows the exact existing wrapping pattern used atsandbox/host.py(catchSandboxError, re-raise as aHarnessErrorsubclass withraise ... from exc). No new retry/dispatch mechanism was invented — the genericexcept HarnessErrorloop inagents.pyalready dispatches on.retry/.retry_delays.- Every other caller of
Client.acquire/attach/provision/ephemeral(doctor.py,browser/login.py,workflows.py::_ensure_host,browser/sessions.py) was checked — none depended on the previously-unwrapped raw SDK errors propagating, so wrapping doesn't change their behavior. - The reachability-error tuple is deliberately narrow and backed by a dedicated cancellation test proving
CancelledErrorisn't misclassified. - New tests assert behavior (retry counts, sleep delays, idempotency-key stability,
failure_codepersistence) rather than implementation/mock-call shape — consistent with siblingHarnessOverloadedError/HarnessRateLimitErrortests. - No edits outside the stated scope.
One low, non-blocking cosmetic note: a couple of test functions use function-local import asyncio/import asyncssh rather than a module-level import — consistent with this file's existing convention elsewhere, not worth a required change.
Low-only findings, so no follow-up sub-issue was filed.
There was a problem hiding this comment.
Verification
Verdict: PASS — all five acceptance criteria of the plan (revision 2) are satisfied by this diff (implementation revision 1, round 1).
- AC1 (SDK translation at the
Clientboundary):backend/druks/harnesses/exceptions.pyaddsHarnessSandboxProvisioningError(HarnessSandboxError), overriding onlycode = "sandbox_provisioning"and inheritingretry/retry_delays(TRANSIENT,(60, 300)) as specified.client.py'screate_hostpath catches exactly(SandboxProvisioningError, SandboxUnavailableError)and re-raises withfrom exc(cause preserved);attach/get_hostcatches onlySandboxUnavailableError.SandboxAPIErrorand its fatal subclasses are untouched. Covered bytest_acquire_translates_transient_create_failuresandtest_attach_translates_unavailable_lookup_failureintest_sandbox_lifecycle.py— pass. - AC2 (acquire-time SSH/helper rollback + classification): the existing rollback (
aclose,_best_effort_delete, key unlink) runs unconditionally; only the re-raise is refined via a narrow_ACQUIRE_SETUP_REACHABILITY_ERRORStuple.CancelledError(aBaseException) correctly falls through unclassified. Covered bytest_acquire_classifies_setup_reachability_failure_after_rollbackandtest_acquire_setup_cancellation_propagates_unclassified— pass. - AC3 (in-run retry, stable idempotency key):
Agent.__call__, the in-step retry loop, andWorkflow._ensure_hostare unchanged, as planned — the new error type flows through existingHarnessError/Retry.TRANSIENTmachinery. Keys are deterministic (f"{workflow_id}:{step}",f"{workflow_id}:sandbox") and unaffected by retry count. Covered by three focused tests intest_agents.py(durable retry, in-step retry, reused-host key stability) — pass. - AC4 (exhaustion → classified failure_code persisted):
_execute_run's existing genericHarnessErrorhandling passesexc.codethrough unmodified.test_provisioning_failure_exhausts_retries_with_classified_codeandtest_an_exhausted_provisioning_failure_stamps_its_code(test_run_state.py) confirmRun.failure_code == "sandbox_provisioning"— pass. - AC5 (fatal SDK errors excluded from retry): representative fatal-error tests (
SandboxAuthErrorat the lifecycle layer,SandboxValidationErrorat the agent layer) confirm no rollback attempt, no retry sleep, error propagates as-is — pass.
Verification profile:
uv run ruff check backend— pass ("All checks passed!")uv run ruff format --check backend— pass (342 files formatted)uv run pytest backend/— pass (1254 passed; focused modules also pass in isolation, 72 passed)- GitHub check "On Pull Request Backend / checks" — SUCCESS for head_sha
777d189 - Frontend lint/build/test — not applicable; no frontend files touched by this diff
Findings: none blocking. Six-sweep review (AC coverage, plan-specified approach, test coverage, dependency/lockfile changes, input validation/error boundaries, out-of-scope side effects) found nothing that contradicts the plan, the ticket, or the "ruled out" list.
Open findings (advisory, non-blocking): two observations from the verification pass, both superseded by the more concrete write-up under Code review below — see that section and the filed follow-up (ENG-866).
Code review
Clean-room code-review lens (diff-only, no ticket/plan context) found no high-severity issues. Two non-blocking findings:
- Medium —
backend/druks/sandbox/client.py,Client.acquire'screate_host-time exception handler doesn't calldelete_hoston translation, unlike the sibling post-create rollback path a few lines below. Likely intentional (the exception carries no host id to delete), but undocumented. See inline comment. Filed as follow-up ENG-866 (child of ENG-822) since this is a genuine question worth confirming, not something this diff needs to resolve. - Low —
backend/druks/sandbox/exceptions.py'sSandboxUnreachabledocstring says "terminal for the affected run," but the new setup-reachability handling inclient.pyreclassifies it as transient-retryable at one call site. Defensible given the different context (early "never became usable" vs. established-run tail-loop), but the shared docstring no longer reflects both use sites. Noted for whoever next touches that file; no action required now.
Everything else: the new exception fits the existing harness taxonomy exactly, SDK exception narrowing correctly preserves fatal passthrough and cancellation, new tests are behavior-focused (assert retry counts/keys/final codes, not call sequences) and follow the shape of existing sibling tests, no dead code, no duplicated abstractions, no out-of-scope edits.
There was a problem hiding this comment.
Advisory (non-blocking), from the code-review lens: this except (SandboxProvisioningError, SandboxUnavailableError) handler on the create_host path wraps and re-raises HarnessSandboxProvisioningError without calling delete_host, unlike the sibling post-create setup-failure rollback path a bit further down (which calls _best_effort_delete). The drukbox SDK's docstring for SandboxProvisioningError notes the host row stays in error state server-side and suggests calling delete_host to release partial provider state.
This may well be intentional — the exception only carries a detail string with no host id, so there may be nothing to delete from here, and the ticket's safety argument already relies on idempotency-key dedup on the next create_host rather than immediate cleanup. But the diff doesn't document that assumption anywhere. A one-line comment confirming it (or a note if it's a real gap) would help the next reader.
Filed as a non-blocking follow-up: ENG-866.
| # raw error would otherwise bypass agent retry and record an | ||
| # empty failure code. Cancellation and unrelated | ||
| # local/programming errors keep their own type and propagate. | ||
| if isinstance(error, _ACQUIRE_SETUP_REACHABILITY_ERRORS): |
There was a problem hiding this comment.
I would prefer except _ACQUIRE_SETUP_REACHABILITY_ERRORS with a shortened version of this comment, and its ok to repeat the sandbox aclose and other stuff, to avoid the isinstance which just does what except does
|
|
||
| _DRUKS_SANDBOX_LOCAL_SCRIPT = Path(__file__).parent / "druks-sandbox.sh" | ||
|
|
||
| # A freshly created host that we can't reach or set up over SSH is a |
There was a problem hiding this comment.
trim this comment and use simplified english
|
|
||
|
|
||
| class HarnessSandboxProvisioningError(HarnessSandboxError): | ||
| """A control-plane provisioning or transport failure while creating or |
There was a problem hiding this comment.
trim this docstring and use simplified english
|
Hi @czpython — three style/structure changes picked up from your review, implementing them now:
No logic changes — same rollback behavior and same classification, just cleaner structure and prose. Will post an updated commit. |
…auses, trim comment and docstring Co-Authored-By: commonzenpython@gmail.com <commonzenpython@gmail.com>
Addressed all three inline style comments in 067ff78: split the acquire rollback into two explicit except clauses (reachability vs. BaseException), trimmed the _ACQUIRE_SETUP_REACHABILITY_ERRORS block comment, and trimmed the HarnessSandboxProvisioningError docstring. No logic changes.
There was a problem hiding this comment.
Verdict: PASS — round 2 (implementation revision 2) satisfies all five acceptance criteria of plan revision 2, and correctly applies the three style-only edits czpython requested in the round-1 CHANGES_REQUESTED review.
What changed this round: backend/druks/sandbox/client.py and backend/druks/harnesses/exceptions.py only, 15 insertions / 27 deletions, no test files touched — exactly matching the "three edits; no logic changes, no new tests" instruction.
Client.acquire's setup-failure handler was split from a singleexcept BaseException as error:+isinstance(error, _ACQUIRE_SETUP_REACHABILITY_ERRORS)guard into two explicit clauses:except _ACQUIRE_SETUP_REACHABILITY_ERRORS as error:(rollback, then raiseHarnessSandboxProvisioningError(...) from error) andexcept BaseException:(identical rollback, then bareraise). Verified byte-identical rollback (sandbox.aclose(),self._best_effort_delete(api, record.id),key_path.unlink(missing_ok=True)) in both branches, and thatasyncio.CancelledErrorstill falls to the second clause and propagates unclassified, since it's aBaseExceptionnot in the reachability tuple.- The
_ACQUIRE_SETUP_REACHABILITY_ERRORSblock comment was trimmed to two plain-English lines. - The
HarnessSandboxProvisioningErrordocstring was trimmed to two plain-English lines.
The sibling create_host-time exception handling a few lines above is untouched, as required.
Acceptance criteria: AC1–AC5 all PASS. AC1/AC2 cover the exact code refactored this round and were re-verified behaviorally identical by diff reading and by the still-passing round-1 tests (test_acquire_classifies_setup_reachability_failure_after_rollback, test_acquire_setup_cancellation_propagates_unclassified, test_acquire_releases_host_when_helper_upload_fails, test_acquire_translates_transient_create_failures, test_acquire_passes_through_fatal_create_failure). AC3–AC5 cover agent-level retry/idempotency/exhaustion behavior untouched by this refactor, confirmed via test_provisioning_failure_recovers_through_durable_retry, test_provisioning_failure_recovers_in_step_retry, test_reused_host_retry_presents_a_stable_idempotency_key, test_provisioning_failure_exhausts_retries_with_classified_code, test_an_exhausted_provisioning_failure_stamps_its_code, test_fatal_sdk_error_does_not_trigger_agent_retry.
Verification: uv run ruff check backend clean; uv run ruff format --check backend clean (342 files); full uv run pytest backend/ — 1254 passed, 0 failures; GitHub check "On Pull Request Backend / checks" is green for head 067ff78. No frontend files touched, so frontend lint/build/test commands are not applicable this round.
No regressions introduced by this round; no unaddressed prior blockers (round 1 was itself a clean pass with no blocking findings).
Open findings
Carried forward from round 1, unchanged by this round's diff and not re-raised as blocking:
- (medium, non-blocking)
Client.acquire's create_host-time exception handler doesn't calldelete_hostbefore re-raisingHarnessSandboxProvisioningError, unlike the sibling post-create setup-failure rollback path. Already filed as follow-up ENG-866 (child of ENG-822). - (low, non-blocking)
SandboxUnreachable's docstring inbackend/druks/sandbox/exceptions.pysays "terminal for the affected run," but this PR's setup-time reachability path now reclassifies it as transient-retryable at one call site. No action required; noted for whoever next touches that file.
Code review
Clean-room review of the round-2 diff reported no findings. The refactor is behaviorally equivalent to round 1's approved code: the two-except-clause form correctly preserves cancellation/unrelated-error passthrough, mirrors the existing except-clause classification style already used a few lines away in attach() (except SandboxNotFoundError / except SandboxUnavailableError), and the comment/docstring trims remove implementation narration while keeping the why. No new abstractions, no scope creep, no dead code, no test changes. No follow-up issue filed — no medium/high findings this round.
Linear ticket: ENG-822
Plan
Approach
Use the existing harness failure taxonomy rather than adding a second classified-error family.
HarnessSandboxErrorinbackend/druks/harnesses/exceptions.pyalready owns the required transient retry schedule, but itssandboxcode cannot satisfy the operator-pinnedsandbox_provisioningclassification. Add a provisioning-specific subclass beside it that overrides onlycode, inheriting the established retry metadata.Translate failures at the shared
Clientboundary, not behind an agent-context check. This applies consistently to agents,doctor.py, and browser callers; their existing broad reporting/wrapping behavior requires no caller changes.Implementation
backend/druks/harnesses/exceptions.py, add theHarnessSandboxErrorspecialization withcode = "sandbox_provisioning"; do not duplicateretryorretry_delays.backend/druks/sandbox/client.py, translate only the current SDK's transient control-plane shapes:SandboxProvisioningErrorandSandboxUnavailableErrorfrom host creation.SandboxUnavailableErrorfrom host lookup while attaching.Preserve each original exception as the cause and leave the
SandboxAPIErrorbase and its fatal subclasses untouched.Client.acquire's existing post-create rollback path so a failure establishing that the fresh VM is reachable and usable during SSH/helper setup is classified as sandbox provisioning after cleanup. Do not classify cancellation or unrelated local/programming failures.backend/druks/sandbox/exceptions.pyunchanged: it remains the sandbox layer's ownSandboxErrorvocabulary, while the client boundary emits the harness taxonomy consumed by orchestration.Agent.__call__and_execute_rununchanged. The new subtype automatically enters their existing bounded transient retry and classified run-failure branches.backend/druks/agents.pyandWorkflow._ensure_host; retries re-enter_runwith the same workflow/agent identity, so no new key generation or persistence is needed.backend/tests/test_sandbox_lifecycle.pyfor create/attach SDK translation, fresh-host SSH/helper failure classification after rollback, cause preservation, and representative fatal passthrough.backend/tests/test_agents.pyfor recovery and exhaustion through the durable and in-step retry paths, capturing stable keys for ephemeral and workflow-reused acquisition.backend/tests/test_run_state.pyfor persistence of exhausted provisioning failures assandbox_provisioning.Scope boundaries
No schema, migration, API, frontend, documentation, or non-agent caller changes are required. Out of scope: drukbox-side fixes (error formatting or
create_vmtimeout headroom), and retrying genuinely fatal provisioning errors such as bad images or authentication failures.Verification
Run focused tests for the three changed backend test modules, then the backend-only profile:
uv run pytest backend/,uv run ruff check backend, anduv run ruff format --check backend.Ruled out
backend/druks/sandbox/exceptions.py: that module contains the sandbox layer's ownSandboxErrorfamily and would mix unrelated bases while reversing the established sandbox-to-harness translation direction.HarnessSandboxErrordirectly: retries would work, but exhausted runs would retain the genericsandboxcode instead of the requiredsandbox_provisioningclassification.SandboxAPIErrorbase as transient: this would retry authentication, validation, conflict, and generic response failures that cannot recover without changed credentials or inputs.acquire,provision, andattachare shared boundaries, and caller-specific gating would make identical provisioning failures depend on who invoked the client.Acceptance Criteria
create_hostfailures represented by drukboxSandboxProvisioningErrororSandboxUnavailableError, andget_hostattach failures represented bySandboxUnavailableError, are translated at the shared sandbox-client boundary into a provisioning-specificHarnessSandboxErrorsubclass withcode="sandbox_provisioning"; the existing transient retry policy is inherited and the SDK exception is preserved as the cause.backend/tests/test_sandbox_lifecycle.pyexercise create and attach translation and inspect the resulting type, code, retry classification, and exception cause.Client.acquireperforms its existing rollback—closing the sandbox, deleting the host, and removing its key—then raises the same classified provisioning failure from the original reachability/setup error. Cancellation and unrelated local/programming errors remain unchanged.backend/tests/test_agents.pysimulate failure then recovery through both retry paths, assert the applicable sleep mechanism, and capture unchanged keys across ephemeral and reused-host attempts.code="sandbox_provisioning", and_execute_runpersistsRun.failure_codeassandbox_provisioningrather than an empty string.backend/tests/test_run_state.pyverifies persistence of the classified failure code.