Skip to content

Commit 8d17949

Browse files
authored
refactor(sandbox): normalize Daytona recovery errors (#184)
## Why Daytona currently reports an unhealthy sandbox host with a structured HTTP 503 body but no provider error code. The sandbox lifecycle should not depend on provider message text. ## What changed - validate the Daytona error projection at the adapter boundary - normalize the specific host-recovery response to an internal `daytona_host_recovering` code - make lifecycle classification depend only on the internal code and HTTP status - document the normalized adapter-to-lifecycle contract This preserves the guarded same-volume runtime replacement behavior merged in #183 while keeping provider response details isolated to the Daytona adapter. ## Verification - `pnpm lint` - `pnpm typecheck` - `pnpm turbo build --force` - `pnpm deadcode` - `pnpm architecture:check` - `pnpm turbo skills:build` - captured the live Daytona 503 response shape for the affected sandbox without exposing credentials Production behavior will be exercised after merge and exact-SHA Cloudflare deployment.
1 parent 7ca2b51 commit 8d17949

2 files changed

Lines changed: 36 additions & 6 deletions

File tree

apps/agent-worker/README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,9 +83,10 @@ completed step instead of losing an in-memory coroutine. Transcript publication
8383
event keys and an atomic SQLite receipt, so Workflow step replay cannot duplicate visible parts.
8484
Preparation uses a multi-minute durable exponential-backoff window for transient provider
8585
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.
86+
the Daytona adapter normalizes the provider's structured `503` response to an internal error code,
87+
then the lifecycle verifies the active-run lease and canonical volume mount before replacing the
88+
stopped container on the same isolated workspace-volume subpath. This preserves user files while
89+
avoiding an indefinite dependency on one unhealthy runner.
8990
There is no application step, token, duration, or cost ceiling; semantic completion ends the loop,
9091
while per-operation timeouts and the platform Workflow limit remain operational safeguards.
9192
The Worker pins Cloudflare's paid-plan maximum subrequest allowance because external provider,

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

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,18 @@ 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 =
37+
const DAYTONA_HOST_RECOVERY_ERROR_CODE = "daytona_host_recovering";
38+
const DAYTONA_HOST_RECOVERY_MESSAGE_PREFIX =
3839
"sandbox start is temporarily unavailable while the sandbox's host recovers";
3940

41+
const DaytonaErrorResponseSchema = z
42+
.object({
43+
error: z.string(),
44+
message: z.string(),
45+
statusCode: z.number().int(),
46+
})
47+
.strip();
48+
4049
interface DaytonaClientConfig {
4150
apiKey: string;
4251
apiUrl: string;
@@ -75,7 +84,7 @@ export function isDaytonaHostRecoveryStartError(error: unknown): boolean {
7584
if (
7685
current instanceof DaytonaApiError &&
7786
current.status === 503 &&
78-
current.message.toLowerCase().includes(DAYTONA_HOST_RECOVERY_START_MESSAGE)
87+
current.code === DAYTONA_HOST_RECOVERY_ERROR_CODE
7988
) {
8089
return true;
8190
}
@@ -655,13 +664,15 @@ export class DaytonaClient {
655664
}
656665

657666
async function toApiError(res: Response, operation?: string): Promise<DaytonaApiError> {
667+
let code: string | undefined;
658668
let details: unknown;
659669
let message = `Daytona request failed (HTTP ${res.status}${operation ? `; ${operation}` : ""})`;
660670
try {
661671
const text = await readBoundedResponseText(res, DAYTONA_ERROR_RESPONSE_MAX_BYTES);
662672
if (text.length > 0) {
663673
try {
664674
const parsed: unknown = JSON.parse(text);
675+
code = providerErrorCode(parsed, res.status);
665676
details = parsed;
666677
message = providerErrorMessage(parsed) ?? message;
667678
} catch {
@@ -671,7 +682,25 @@ async function toApiError(res: Response, operation?: string): Promise<DaytonaApi
671682
} catch {
672683
// ignore body read failures
673684
}
674-
return new DaytonaApiError(res.status, message, { details });
685+
return new DaytonaApiError(res.status, message, {
686+
...(code ? { code } : {}),
687+
details,
688+
});
689+
}
690+
691+
function providerErrorCode(value: unknown, responseStatus: number): string | undefined {
692+
const result = DaytonaErrorResponseSchema.safeParse(value);
693+
if (!result.success) return undefined;
694+
const providerError = result.data;
695+
if (
696+
responseStatus === 503 &&
697+
providerError.statusCode === 503 &&
698+
providerError.error === "Service Unavailable" &&
699+
providerError.message.toLowerCase().startsWith(DAYTONA_HOST_RECOVERY_MESSAGE_PREFIX)
700+
) {
701+
return DAYTONA_HOST_RECOVERY_ERROR_CODE;
702+
}
703+
return undefined;
675704
}
676705

677706
function providerErrorMessage(value: unknown): string | undefined {

0 commit comments

Comments
 (0)