Skip to content

Commit 7ca2b51

Browse files
authored
fix: fail over unhealthy Daytona sandbox hosts (#183)
## Summary - Detect Daytona's explicit host-recovery start rejection at the provider boundary. - Replace only the stopped canonical runtime while retaining the user's mounted workspace-volume subpath. - Extend durable preparation retries so remaining transient provider failures do not become immediate failed runs. ## Architecture A run still resolves the single canonical user sandbox first. If Daytona reports that the sandbox's assigned host is recovering, the existing runtime-replacement fence verifies the active-run lease and canonical volume identity, removes the disposable container, and creates one replacement attached to the same isolated volume subpath. Cloudflare Workflow retries remain the durable fallback around preparation. ## Decisions Made | Decision | Choice | Alternatives considered | Reasoning | | --- | --- | --- | --- | | Host-local recovery | Replace the disposable runtime | Wait indefinitely; fail the run | The workspace is on a separate persistent volume, so failover preserves user files and avoids dependence on one unhealthy runner. | | Failure classification | Match Daytona's explicit 503 host-recovery rejection | Replace on every 503 | A narrow provider-boundary classifier prevents destructive failover during platform-wide outages. | | Remaining transient failures | Six exponential-backoff preparation retries | Tight polling inside the Worker | Durable retries do not hold an isolate or hammer Daytona. | ## Edge Cases Handled | Scenario | Handling | | --- | --- | | Another run owns the sandbox | Existing lease fence refuses replacement. | | Sandbox identity or volume mount is ambiguous | Existing canonical-runtime assertions fail closed. | | Daytona changes or omits the host-recovery message | No forced replacement; durable preparation retries apply. | | Replacement creation is temporarily unavailable | The Workflow retries the idempotent preparation step. | ## Verification - [x] `pnpm lint` - [x] `pnpm typecheck` - [x] `pnpm turbo build --force` - [x] `pnpm deadcode` - [x] `pnpm architecture:check` - [x] `pnpm turbo skills:build` - [ ] Resume the failed production Pomodoro run after deployment and verify the replacement sandbox preserves the project and serves the generated preview.
1 parent 37219e3 commit 7ca2b51

5 files changed

Lines changed: 62 additions & 10 deletions

File tree

apps/agent-worker/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,11 @@ provider keys and sandbox capabilities are reacquired inside the active step and
8181
Workflow storage. A Worker isolate or Durable Object eviction therefore resumes from the last
8282
completed step instead of losing an in-memory coroutine. Transcript publication uses deterministic
8383
event keys and an atomic SQLite receipt, so Workflow step replay cannot duplicate visible parts.
84+
Preparation uses a multi-minute durable exponential-backoff window for transient provider
85+
failures. Daytona's explicit host-recovery start rejection is treated as a runtime failover signal:
86+
after the active-run lease and canonical volume mount are verified, the stopped container is
87+
replaced on the same isolated workspace-volume subpath. This preserves user files while avoiding
88+
an indefinite dependency on one unhealthy runner.
8489
There is no application step, token, duration, or cost ceiling; semantic completion ends the loop,
8590
while per-operation timeouts and the platform Workflow limit remain operational safeguards.
8691
The Worker pins Cloudflare's paid-plan maximum subrequest allowance because external provider,

apps/agent-worker/src/durable-objects/agent-run-workflow.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,10 @@ import {
4747
WorkflowToolStepResultSchema,
4848
} from "./agent-run-workflow-runtime";
4949

50-
const PREPARE_STEP = stepConfig("10 minutes", 3);
50+
// A stopped Daytona sandbox can temporarily reject starts while its host recovers.
51+
// Keep that provider recovery inside the durable preparation step so a transient
52+
// host event does not become a user-visible failed run.
53+
const PREPARE_STEP = stepConfig("10 minutes", 6);
5154
const MODEL_STEP = stepConfig("5 minutes", 3);
5255
const TOOL_STEP = stepConfig("15 minutes", 2);
5356
const STATE_STEP = stepConfig("2 minutes", 5);

apps/agent-worker/src/durable-objects/project-sandbox-runtime-handle.ts

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { DaytonaClient, type DaytonaSandbox } from "@cheatcode/agent-core/tools/code";
1+
import {
2+
DaytonaClient,
3+
type DaytonaSandbox,
4+
isDaytonaHostRecoveryStartError,
5+
} from "@cheatcode/agent-core/tools/code";
26
import { previewHostnameForWorker, resolveWorkerSecret } from "@cheatcode/env";
37
import { APIError, createLogger } from "@cheatcode/observability";
48
import { performAccountDeletion } from "./project-sandbox-account-deletion";
@@ -59,6 +63,8 @@ interface RuntimeState {
5963
workspaceState: ProjectSandboxWorkspaceState | undefined;
6064
}
6165

66+
type RuntimeReplacementReason = "configuration_changed" | "daytona_host_recovery";
67+
6268
interface SandboxLeaseRuntime {
6369
withCleanupSignal: <Result>(operation: () => Promise<Result>) => Promise<Result | undefined>;
6470
withOwnerRegistration: <Result>(
@@ -265,11 +271,21 @@ async function ensureSandbox(state: RuntimeState, startingRunId?: string): Promi
265271
if (state.cache.sandboxId && Date.now() - state.cache.startedVerifiedAtMs < STARTED_REVERIFY_MS) {
266272
return state.cache.sandboxId;
267273
}
268-
const resolved = await inspectSandbox(state);
269-
if (typeof resolved === "string") {
270-
return resolved;
274+
try {
275+
const resolved = await inspectSandbox(state);
276+
if (typeof resolved === "string") {
277+
return resolved;
278+
}
279+
return replaceSandboxRuntime(state, startingRunId, "configuration_changed");
280+
} catch (error) {
281+
if (!isDaytonaHostRecoveryStartError(error)) {
282+
throw error;
283+
}
284+
createLogger().warn("sandbox_host_recovery_failover_started", {
285+
sandboxId: state.identity.sandboxName(),
286+
});
287+
return replaceSandboxRuntime(state, startingRunId, "daytona_host_recovery");
271288
}
272-
return replaceSandboxRuntime(state, startingRunId);
273289
}
274290

275291
async function restartSandboxForWorkspaceRecovery(
@@ -376,7 +392,11 @@ async function activateResolvedSandbox(
376392
return resolved.id;
377393
}
378394

379-
async function replaceSandboxRuntime(state: RuntimeState, startingRunId?: string): Promise<string> {
395+
async function replaceSandboxRuntime(
396+
state: RuntimeState,
397+
startingRunId: string | undefined,
398+
reason: RuntimeReplacementReason,
399+
): Promise<string> {
380400
if (state.isSandboxRuntimeUpdateInProgress) {
381401
throw sandboxRuntimeUpdatePending(state.env.DAYTONA_SANDBOX_SNAPSHOT);
382402
}
@@ -391,8 +411,8 @@ async function replaceSandboxRuntime(state: RuntimeState, startingRunId?: string
391411
let resolved: DaytonaSandbox;
392412
try {
393413
resolved = await state.provisioning.resolve(daytona);
394-
if (!state.provisioning.isDesired(resolved)) {
395-
resolved = await replaceSandboxRuntimeExclusive(state, daytona, resolved);
414+
if (reason === "daytona_host_recovery" || !state.provisioning.isDesired(resolved)) {
415+
resolved = await replaceSandboxRuntimeExclusive(state, daytona, resolved, reason);
396416
}
397417
} catch (error) {
398418
throw toUpstreamError(
@@ -412,6 +432,7 @@ async function replaceSandboxRuntimeExclusive(
412432
state: RuntimeState,
413433
daytona: DaytonaClient,
414434
current: DaytonaSandbox,
435+
reason: RuntimeReplacementReason,
415436
): Promise<DaytonaSandbox> {
416437
state.provisioning.assertRuntimeReplacementSafe(current);
417438
await prepareForSandboxReplacement(state);
@@ -421,6 +442,7 @@ async function replaceSandboxRuntimeExclusive(
421442
throw sandboxRuntimeUpdatePending(state.env.DAYTONA_SANDBOX_SNAPSHOT);
422443
}
423444
createLogger().info("sandbox_runtime_replaced", {
445+
reason,
424446
sandboxId: state.identity.sandboxName(),
425447
snapshot: state.env.DAYTONA_SANDBOX_SNAPSHOT,
426448
});

packages/agent-core/src/tools/code/daytona-client.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ const DAYTONA_FILE_LIST_MAX_ITEMS = 1_000;
3434
const DAYTONA_SANDBOX_PAGE_MAX_ITEMS = 100;
3535
const DAYTONA_SESSION_COMMAND_MAX_ITEMS = 1_000;
3636
const DAYTONA_VOLUME_NAME_MAX_CHARACTERS = 100;
37+
const DAYTONA_HOST_RECOVERY_START_MESSAGE =
38+
"sandbox start is temporarily unavailable while the sandbox's host recovers";
3739

3840
interface DaytonaClientConfig {
3941
apiKey: string;
@@ -66,6 +68,22 @@ export class DaytonaApiError extends Error {
6668
}
6769
}
6870

71+
/** Identifies Daytona's host-local start rejection so callers can fail over safely. */
72+
export function isDaytonaHostRecoveryStartError(error: unknown): boolean {
73+
let current = error;
74+
for (let depth = 0; depth < 3; depth += 1) {
75+
if (
76+
current instanceof DaytonaApiError &&
77+
current.status === 503 &&
78+
current.message.toLowerCase().includes(DAYTONA_HOST_RECOVERY_START_MESSAGE)
79+
) {
80+
return true;
81+
}
82+
current = current instanceof Error ? current.cause : undefined;
83+
}
84+
return false;
85+
}
86+
6987
// ---------------------------------------------------------------------------
7088
// Response schemas project provider payloads down to fields used by the runtime.
7189
// ---------------------------------------------------------------------------

packages/agent-core/src/tools/code/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@ export type {
55
DaytonaVolume,
66
SandboxDestroyResult,
77
} from "./daytona-client";
8-
export { DaytonaApiError, DaytonaClient } from "./daytona-client";
8+
export {
9+
DaytonaApiError,
10+
DaytonaClient,
11+
isDaytonaHostRecoveryStartError,
12+
} from "./daytona-client";
913

1014
export {
1115
DeleteFileInputSchema,

0 commit comments

Comments
 (0)