Skip to content

Add Daytona sandbox provider - #1513

Merged
hemildesai merged 16 commits into
NVIDIA-NeMo:mainfrom
hemildesai:hemil/daytona-sandbox
Jul 10, 2026
Merged

Add Daytona sandbox provider#1513
hemildesai merged 16 commits into
NVIDIA-NeMo:mainfrom
hemildesai:hemil/daytona-sandbox

Conversation

@hemildesai

@hemildesai hemildesai commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

Stacked on #1377.

Closes #1687

Adds a Daytona-backed sandbox provider to the sandbox API provider registry, including SDK dependency wiring and provider lifecycle/exec/file helpers.

Smoke notes:

  • Provider import and server startup passed against the rebased sandbox API branch.
  • The latest 16-sample Daytona SWE smoke completed at the Gym layer, but 12/16 rows hit Daytona org disk quota during sandbox creation (Total disk limit exceeded. Maximum allowed: 30GiB), so the observed 1/16 reward is not a meaningful model-quality pass rate.
  • Successful live sandbox labels were observed during the run with sandbox-api: daytona-sdk.

Daytona full SWE-bench Verified validation

  • Run: Kubernetes job hemild-daytona-full-500-c64-014139 completed successfully (1/1, pod 0 restarts).
  • Artifact: /mnt/rl-workspace/hemild/gym_eval/refactor/runs/mini_swe_agent_2_ng_collect_rollouts/20260624-014139-daytona-full-verified-c64-step1800/results/mini_swe_agent_2_sglang_qwen35_dflash_daytona_full500.jsonl
  • Scale/result: 500/500 rollout rows, 324/500 solved, pass@1 / avg rollout reward 0.648 (64.8%).
  • Key config: SWE-bench Verified, Daytona provider, Qwen 3.5 27B via SGLang, max concurrency 64, enable_thinking=true, temperature=0.6, top_p=0.95, max_output_tokens=32768, step timeout 1800s, per-command timeout 1500s, Daytona command retries 0.
  • Infra/model/Gym error summary: 31/500 runtime error rows (6.2% eval error rate): 10 Daytona blank /process/execute HTTP/request timeouts, 9 Daytona command-timeout 408s, 10 Daytona container-IP resolution failures, 1 Daytona 502 Bad Gateway, and 1 model litellm/API timeout. No separate Gym schema/config failure class was observed; remaining failures were normal model/test failures.

@copy-pr-bot

copy-pr-bot Bot commented Jun 3, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

Comment on lines +702 to +713
if allow_partial:
prefix: list[SandboxHandle] = []
for result in results:
if isinstance(result, Exception):
break
prefix.append(result)
return prefix
for handle in handles:
await self.close(handle, delete=True)
raise DaytonaCreateError(
f"One or more Daytona sandboxes failed during batch create; failed={len(errors)}, requested={count}"
) from errors[0]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

when allow_partial is True this function returns only the successful sandboxes up to the first exception, ignoring any later successful handles, which leaks/hides successful sandboxes. Return a structured success/error result, or delete every success not returned; cleanup should attempt all deletes and aggregate cleanup errors.

@hemildesai hemildesai Jun 23, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

create_batch now waits for every create attempt, returns all successful handles when allow_partial=True, and cleans up every successful handle while aggregating cleanup errors when partial results are not allowed. Commit: hemildesai@57d9689

Comment on lines +674 to +686
async def create(self, spec: SandboxSpec) -> SandboxHandle:
spec = _normalize_spec(spec)
max_attempts = self._create.retries + 1
for attempt_number in range(1, max_attempts + 1):
try:
return await self._create_once(spec)
except Exception as e:
if attempt_number >= max_attempts or not _is_retryable_create_error(e):
raise
await asyncio.sleep(
self._retry_sleep_s(attempt_number, self._create.retry_delay_s, self._create.retry_max_delay_s)
)
raise RuntimeError("Daytona create retry loop did not run")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make create retries idempotent. If Daytona creates a sandbox and the client times out before receiving the ID, this retry path can create a duplicate that cleanup can't identify.

@hemildesai hemildesai Jun 23, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

create retries now use a retry-stable generated Daytona sandbox name when the caller has not provided daytona.name; explicit names are preserved. Ambiguous create timeouts remain non-retried. Commit: hemildesai@e703f21

LOGGER.debug(
"Ignoring unsupported Daytona %s settings: %s", config_cls.__name__, ", ".join(unsupported_keys)
)
return config_cls(**{key: val for key, val in value.items() if key in field_names})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fail fast on unsupported Daytona provider config keys instead of silently dropping them. A typo in lifecycle/network settings (for example auto_delete_interval or network_block_all) can make the caller think a safety control is enabled when it is not.

Suggested direction:

if unsupported_keys:
    raise ValueError(
        f"Unsupported Daytona {config_cls.__name__} settings: {', '.join(unsupported_keys)}"
    )

@hemildesai hemildesai Jun 23, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unsupported Daytona provider config keys now fail fast with a ValueError, including a test for unknown create settings. Commit: hemildesai@57d9689

return ImageParams(**kwargs)
if snapshot_id is not None:
kwargs["snapshot"] = snapshot_id
return SnapshotParams(**kwargs)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reject resources when creating from a Daytona snapshot, or split the API shape so image-backed and snapshot-backed creation cannot be confused. Right now resources are applied only to CreateSandboxFromImageParams; with snapshot_id, the same SandboxSpec.resources value is silently ignored.

Suggested direction:

if snapshot_id is not None:
    if spec.resources:
        raise ValueError("Daytona snapshot creation does not support resource overrides")
    kwargs["snapshot"] = snapshot_id
    return SnapshotParams(**kwargs)

@hemildesai hemildesai Jun 23, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

snapshot-backed Daytona creation now rejects resource overrides instead of silently ignoring them, with a unit test for that path. Commit: hemildesai@57d9689

timeout_s=self._operations.close_timeout_s,
)
info_status = getattr(info, "status", None)
return _to_sandbox_status(getattr(info_status, "state", info_status))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use Daytona's native sandbox state refresh path here. The async SDK exposes sandbox state as sandbox.state after await sandbox.refresh_data(), so checking raw.status / get_info().status can classify a valid sandbox as UNKNOWN if those attributes are not populated on the SDK object.

Suggested direction:

await self._await_operation(
    lambda: handle.raw.refresh_data(),
    operation="refresh_data",
    sandbox_id=handle.sandbox_id,
    timeout_s=self._operations.close_timeout_s,
)
return _to_sandbox_status(getattr(handle.raw, "state", None))

@hemildesai hemildesai Jun 23, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

status() now prefers Daytona native refresh_data() and reads sandbox.state, with fallbacks only for SDK objects that do not expose that method. Commit: hemildesai@57d9689

operation="process.exec",
sandbox_id=handle.sandbox_id,
timeout_s=float(timeout_s) + self._operations.command_timeout_margin_s if timeout_s is not None else None,
retries=self._command_retry_count() if retries is None else retries,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Default command retries to zero. Retrying create/status/file/delete operations is fine, but retrying arbitrary guest commands can run user work twice if the command reached Daytona and only the response was lost. That can duplicate writes, re-apply patches, or rerun non-idempotent setup/tests.

Suggested direction:

command_retries: int = 0

and keep explicit opt-in for callers that know their command is idempotent.

@hemildesai hemildesai Jun 23, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

command_retries now defaults to zero, so arbitrary guest commands are not retried unless a caller explicitly opts in. Commit: hemildesai@57d9689

return command
if isinstance(user, int):
return f"setpriv --reuid={user} --regid={user} --clear-groups /bin/sh -c {shlex.quote(command)}"
return f"su -s /bin/sh -c {shlex.quote(command)} {shlex.quote(user)}"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Document or enforce that per-command user= support is Linux/POSIX-only for this provider. This wrapper depends on /bin/sh, su, and setpriv, so it will break on Windows snapshots and can also fail on minimal Linux images that do not include those tools.

Suggested direction: either reject non-root user unless the provider is known to be running a POSIX image with the required tools, or leave user unsupported until Daytona exposes provider-native per-command user switching.

@hemildesai hemildesai Jun 23, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

per-command non-root user= switching is now unsupported for Daytona and raises clearly; callers can use create.os_user for sandbox-level user selection until Daytona has native per-command support. Commit: hemildesai@57d9689

if stderr is None and artifacts is not None:
stderr = getattr(artifacts, "stderr", None)
return_code = getattr(response, "exit_code", None)
return SandboxExecResult(stdout=stdout, stderr=stderr, return_code=0 if return_code is None else return_code)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add a provider test for stderr-only output. Daytona's plain process.exec() docs clearly expose result / artifacts.stdout; stderr is clearer on the session-command API than on plain exec. This mapping may be correct, but it should be proven because compiler/test diagnostics often appear only on stderr.

Suggested test shape:

result = await provider.exec(handle, "python -c 'import sys; print("err", file=sys.stderr)'")
assert result.stderr and "err" in result.stderr

@hemildesai hemildesai Jun 23, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a provider unit test covering stderr-only output from Daytona process.exec() artifacts and preserving the non-zero exit code. Commit: hemildesai@57d9689


async def close(self, handle: SandboxHandle, *, delete: bool) -> None:
if not delete:
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clarify the lifecycle semantics here. With delete=False, Daytona close() does not stop, archive, or delete the remote sandbox; it only detaches the client. That is useful for debugging, but dangerous as a default in eval/RL runs because quota-consuming sandboxes can be left alive.

Suggested direction: make the normal eval path delete/ephemeral by default, and expose retention as an explicit debug/retain mode rather than making close(delete=False) look like remote cleanup.

@hemildesai hemildesai Jun 23, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

close(handle) now deletes by default for the normal eval path, while explicit close(delete=False) remains available for debug retention and logs that the remote sandbox is being left alive. Commit: hemildesai@e703f21

@hemildesai
hemildesai force-pushed the hemil/daytona-sandbox branch 2 times, most recently from 13dd946 to 4ba2396 Compare June 9, 2026 16:48
@hemildesai
hemildesai force-pushed the hemil/daytona-sandbox branch from fcc8418 to e703f21 Compare June 23, 2026 03:59
@hemildesai
hemildesai changed the base branch from hemil/sandbox-api-part-1 to main June 23, 2026 03:59
@hemildesai
hemildesai marked this pull request as ready for review June 23, 2026 17:05
@hemildesai
hemildesai requested a review from a team as a code owner June 23, 2026 17:05
cmunley1
cmunley1 previously approved these changes Jun 23, 2026
batch:
concurrency: 3
sandbox_spec:
timeout_s: 18000

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This config value gets ignored: MiniSWESandboxEnvironment reads ttl_s not timeout_s

Suggested change
timeout_s: 18000
ttl_s: 18000

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed in ca552e8: the Daytona smoke config now uses ttl_s: 18000 instead of timeout_s.

Comment on lines +43 to +46
provider_options:
platform:
os: linux
arch: amd64

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Daytona provider does not read these values, and Daytona has no equivalent for {os, arch}. Ideally unknown spec/provider fields should be rejected instead of silently ignored.

Suggested change
provider_options:
platform:
os: linux
arch: amd64
provider_options:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed in ca552e8: removed the ignored platform os/arch block from the smoke config and added Daytona provider_options validation so unsupported provider options now fail fast.

Comment on lines +561 to +575
for key in (
"api_key",
"jwt_token",
"organization_id",
"api_url",
"server_url",
"target",
"connection_pool_maxsize",
"otel_enabled",
):
value = getattr(self._connection, key)
if value is not None:
kwargs[key] = value
self._daytona = AsyncDaytona() if not kwargs else AsyncDaytona(DaytonaConfig(**kwargs))
return self._daytona

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Daytona SDK supports connection_pool_maxsize=None to remove the HTTP connection limit, but that gets filtered out by this provider before constructing DaytonaConfig preventing users from intentionally selecting the unlimited-pool mode.

Suggested fix: preserve which config keys were supplied, or use an explicit unset sentinel, so connection_pool_maxsize: null can be passed through intentionally.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed in ca552e8: tracked explicitly supplied connection config keys so connection_pool_maxsize: null is passed through to DaytonaConfig, with unit coverage.

Comment on lines +731 to +737
handle = SandboxHandle(sandbox_id=str(sandbox.id), provider_name=self.name, raw=sandbox)
try:
await self._verify_created_handle(handle)
except Exception:
await self.close(handle, delete=True)
raise
return handle

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When Daytona creates a sandbox but the readiness probe fails, the provider tries to delete the sandbox. If that delete also fails, the cleanup exception replaces the original probe failure.

Suggested fix: catch cleanup exceptions, log them with sandbox_id, and re-raise the original probe/create exception.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changed in ca552e8: create-probe cleanup now catches/logs delete failures with sandbox_id and re-raises the original probe/create exception, with unit coverage.

hemildesai added 11 commits July 6, 2026 09:15
Signed-off-by: Hemil Desai <hemild@nvidia.com>
Signed-off-by: Hemil Desai <hemild@nvidia.com>
Signed-off-by: Hemil Desai <hemild@nvidia.com>
Signed-off-by: Hemil Desai <hemild@nvidia.com>
Signed-off-by: Hemil Desai <hemild@nvidia.com>
Signed-off-by: Hemil Desai <hemild@nvidia.com>
Signed-off-by: Hemil Desai <hemild@nvidia.com>
Signed-off-by: Hemil Desai <hemild@nvidia.com>
Signed-off-by: Hemil Desai <hemild@nvidia.com>
Signed-off-by: Hemil Desai <hemild@nvidia.com>
Signed-off-by: Hemil Desai <hemild@nvidia.com>
@hemildesai
hemildesai force-pushed the hemil/daytona-sandbox branch from 9e0bd89 to ca552e8 Compare July 6, 2026 16:28
Comment thread nemo_gym/sandbox/providers/daytona/provider.py Outdated
Comment thread responses_api_agents/mini_swe_agent_2/configs/mini_swe_agent_daytona_smoke.yaml Outdated
Comment thread pyproject.toml
Signed-off-by: Hemil Desai <hemild@nvidia.com>
Signed-off-by: Hemil Desai <hemild@nvidia.com>
ananthsub
ananthsub previously approved these changes Jul 7, 2026
Comment thread nemo_gym/sandbox/providers/daytona/configs/daytona.yaml Outdated
Comment thread responses_api_agents/mini_swe_agent_2/configs/mini_swe_agent_daytona_smoke.yaml Outdated
Signed-off-by: Hemil Desai <hemild@nvidia.com>
ananthsub
ananthsub previously approved these changes Jul 7, 2026
@hemildesai
hemildesai merged commit 40db98b into NVIDIA-NeMo:main Jul 10, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(sandbox): Daytona sandbox provider

5 participants