Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
d26c957
opencode_env: don't retry the install when version resolution fails
jayzuccarelli Aug 10, 2026
22bbbb4
opencode_env: pass pinned version as installer argument
jayzuccarelli Aug 10, 2026
3b122d7
Merge branch 'main' into opencode-install-fatal-version-failure
jayzuccarelli Sep 9, 2026
9dafe91
Merge branch 'main' into opencode-install-fatal-version-failure
burtenshaw Sep 21, 2026
eba7bd7
fix: stop fatal sandbox retries
burtenshaw Sep 21, 2026
20594e6
Merge branch 'main' into opencode-install-fatal-version-failure
burtenshaw Sep 21, 2026
2d1f296
Merge branch 'main' into opencode-install-fatal-version-failure
burtenshaw Sep 21, 2026
4d6cf7a
Merge branch 'main' into opencode-install-fatal-version-failure
burtenshaw Sep 22, 2026
f4cca06
Merge branch 'main' into opencode-install-fatal-version-failure
burtenshaw Sep 22, 2026
70011ac
Merge branch 'main' into opencode-install-fatal-version-failure
burtenshaw Sep 23, 2026
8a61553
Merge branch 'main' into opencode-install-fatal-version-failure
burtenshaw Sep 23, 2026
d2f59df
Merge branch 'main' into opencode-install-fatal-version-failure
burtenshaw Sep 23, 2026
2fff042
Merge branch 'main' into opencode-install-fatal-version-failure
burtenshaw Sep 23, 2026
5e0c014
Merge branch 'main' into opencode-install-fatal-version-failure
burtenshaw Sep 23, 2026
17fd38b
Merge branch 'main' into opencode-install-fatal-version-failure
burtenshaw Sep 24, 2026
29da463
Merge branch 'main' into opencode-install-fatal-version-failure
burtenshaw Sep 25, 2026
0cf3fc7
Merge branch 'main' into opencode-install-fatal-version-failure
burtenshaw Sep 28, 2026
d720258
Merge branch 'main' into opencode-install-fatal-version-failure
burtenshaw Sep 28, 2026
7e72a62
Merge branch 'main' into opencode-install-fatal-version-failure
burtenshaw Sep 29, 2026
5301815
Merge branch 'main' into opencode-install-fatal-version-failure
burtenshaw Sep 29, 2026
4e67fbb
Merge branch 'main' into opencode-install-fatal-version-failure
burtenshaw Sep 29, 2026
6be4ebb
Merge branch 'main' into opencode-install-fatal-version-failure
burtenshaw Sep 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 35 additions & 10 deletions envs/opencode_env/harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@
from .sandbox.base import BgJob, SandboxBackend, SandboxHandle
from .task import OpenCodeTask

# The upstream installer resolves "latest" via api.github.com and prints this
# to stdout (with an empty stderr) when the lookup fails, e.g. on rate limit.
_INSTALL_VERSION_FETCH_ERROR = "Failed to fetch version information"


# Mode B proxy port. In-sandbox paths derive from config.sandbox_home (opencode_runtime).
_PROXY_PORT = 7000
Expand All @@ -71,6 +75,10 @@
Verifier = Callable[[SandboxHandle, OpenCodeTask], VerifyResult]


class _NonRetryableBootstrapError(RuntimeError):
"""Bootstrap failure that provisioning another sandbox cannot fix."""


class OpenCodeSession(ResourceSession):
"""One live OpenCode rollout inside a sandbox.

Expand Down Expand Up @@ -246,6 +254,8 @@ def create(
return self._create_once(
task, seed=seed, episode_id=episode_id, start_agent=start_agent
)
except _NonRetryableBootstrapError:
raise
except Exception as exc: # noqa: BLE001
last_exc = exc
if i + 1 < self._create_attempts:
Expand Down Expand Up @@ -377,12 +387,15 @@ def _exec_with_retry(
attempts: int = 3,
backoff_s: float = 3.0,
label: str = "cmd",
fatal_markers: tuple[str, ...] = (),
):
"""Run ``sandbox.exec`` with exponential backoff on transient failure.

Transient = ``exit_code != 0`` AND empty stderr (SIGKILL / network
blip signature) OR an exception during exec. Final failure is raised
as ``RuntimeError`` carrying the last exit code + stderr.
blip signature) OR an exception during exec. A failure whose stdout
contains one of ``fatal_markers`` is deterministic and not retried,
even if stderr is empty. Final failure is raised as ``RuntimeError``
carrying the last exit code + stderr.
"""
import time

Expand All @@ -399,6 +412,8 @@ def _exec_with_retry(
last_exit = r.exit_code
if last_stderr.strip():
break
if any(marker in last_stdout for marker in fatal_markers):
break
except Exception as exc: # noqa: BLE001
last_stderr = f"{type(exc).__name__}: {exc}"
last_exit = -1
Expand Down Expand Up @@ -437,14 +452,24 @@ def _bootstrap_sandbox(
# Stage 2: install opencode (skipped if a prebaked template already
# has it). curl|bash is flaky — retry with backoff.
if not self._opencode_already_installed(sandbox):
self._exec_with_retry(
sandbox,
build_install_cmd(self._config),
timeout=self._install_timeout_s,
attempts=3,
backoff_s=3.0,
label="opencode install",
)
try:
self._exec_with_retry(
sandbox,
build_install_cmd(self._config),
timeout=self._install_timeout_s,
attempts=3,
backoff_s=3.0,
label="opencode install",
fatal_markers=(_INSTALL_VERSION_FETCH_ERROR,),
)
except RuntimeError as exc:
if _INSTALL_VERSION_FETCH_ERROR in str(exc):
raise _NonRetryableBootstrapError(
"opencode install could not resolve 'latest' from the "
"GitHub API (rate limited?). Pin opencode_version to a "
"release tag to install without touching the API."
) from exc
Comment thread
cursor[bot] marked this conversation as resolved.
raise
Comment thread
burtenshaw marked this conversation as resolved.

sandbox.write_text(
opencode_config_path(self._config),
Expand Down
12 changes: 7 additions & 5 deletions envs/opencode_env/opencode_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,18 +102,20 @@ def build_opencode_json(config: OpenCodeConfig) -> str:
def build_install_cmd(config: OpenCodeConfig) -> str:
"""Return the shell command that installs OpenCode + ensures PATH.

The upstream installer honors ``OPENCODE_VERSION=x.y.z`` for pinning;
leaving it unset tracks ``latest``.
The upstream installer takes ``--version x.y.z`` for pinning (which skips
the api.github.com "latest" lookup); leaving it unset tracks ``latest``.
An env-var prefix on ``curl`` would not reach the ``bash`` side of the
pipe, so the version is passed as an installer argument instead.
"""

version_env = ""
version_args = ""
if config.opencode_version and config.opencode_version != "latest":
version_env = f"OPENCODE_VERSION={config.opencode_version} "
version_args = f" -s -- --version {config.opencode_version}"
home = config.sandbox_home
return (
"set -e && "
f"mkdir -p {home}/.config/opencode {home}/logs/agent {home}/logs/verifier {home}/task {home}/workdir && "
f"{version_env}curl -fsSL https://opencode.ai/install | bash && "
f"curl -fsSL https://opencode.ai/install | bash{version_args} && "
'export PATH="$HOME/.opencode/bin:$PATH" && '
"opencode --version"
)
Expand Down
138 changes: 138 additions & 0 deletions tests/envs/test_opencode_factory_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@

from opencode_env.config import OpenCodeConfig # noqa: E402
from opencode_env.harness import OpenCodeSessionFactory # noqa: E402
from opencode_env.opencode_runtime import build_install_cmd # noqa: E402
from opencode_env.sandbox.base import ExecResult # noqa: E402
from opencode_env.task import OpenCodeTask # noqa: E402


class _FakeSandbox:
Expand Down Expand Up @@ -105,3 +108,138 @@ def _always_fails(task, seed=None, episode_id=None, start_agent=True):
with pytest.raises(RuntimeError, match="persistent create failure"):
factory.create("write a function")
assert calls["n"] == 3


class _ExecScriptedSandbox(_FakeSandbox):
"""exec() returns a fixed failure; echo-ok probes succeed."""

def __init__(self, failure):
super().__init__()
self._failure = failure
self.exec_calls = []

def exec(self, cmd, timeout=None):
self.exec_calls.append(cmd)
if cmd == "echo ok":
return ExecResult(exit_code=0, stdout="ok", stderr="")
return self._failure


def test_exec_with_retry_does_not_retry_fatal_stdout_failure():
# The opencode installer reports version-resolution failure on stdout with
# an empty stderr, which the transient heuristic would otherwise retry.
failure = ExecResult(
exit_code=1, stdout="Failed to fetch version information", stderr=""
)
sandbox = _ExecScriptedSandbox(failure)
factory = _factory(sandbox)
with pytest.raises(RuntimeError, match="failed after 3 attempts"):
factory._exec_with_retry(
sandbox,
"install",
timeout=1,
attempts=3,
backoff_s=0,
fatal_markers=("Failed to fetch version information",),
)
assert len(sandbox.exec_calls) == 1


def test_exec_with_retry_still_retries_silent_failures():
failure = ExecResult(exit_code=137, stdout="", stderr="")
sandbox = _ExecScriptedSandbox(failure)
factory = _factory(sandbox)
with pytest.raises(RuntimeError):
factory._exec_with_retry(
sandbox,
"cmd",
timeout=1,
attempts=3,
backoff_s=0,
fatal_markers=("Failed to fetch version information",),
)
assert len(sandbox.exec_calls) == 3


def test_bootstrap_install_rate_limit_raises_actionable_error(monkeypatch):
failure = ExecResult(
exit_code=1, stdout="Failed to fetch version information", stderr=""
)
sandbox = _ExecScriptedSandbox(failure)
factory = _factory(sandbox)
monkeypatch.setattr(factory, "_opencode_already_installed", lambda s: False)
with pytest.raises(RuntimeError, match="Pin opencode_version"):
factory._bootstrap_sandbox(
sandbox, OpenCodeTask(instruction="write a function")
)
# one echo-ok probe + exactly one install attempt, no retries
assert len([c for c in sandbox.exec_calls if c != "echo ok"]) == 1


@pytest.mark.parametrize(
("failure", "sandbox_attempts", "install_attempts", "error_match"),
[
pytest.param(
ExecResult(
exit_code=1, stdout="Failed to fetch version information", stderr=""
),
1,
1,
"Pin opencode_version",
id="fatal-version-lookup",
),
pytest.param(
ExecResult(exit_code=137, stdout="", stderr=""),
3,
3,
"opencode install failed",
id="transient-install-failure",
),
],
)
def test_create_retries_only_transient_install_failures(
monkeypatch, failure, sandbox_attempts, install_attempts, error_match
):
sandboxes = []

class Backend:
def create(self, **kwargs):
sandbox = _ExecScriptedSandbox(failure)
sandboxes.append(sandbox)
return sandbox

factory = OpenCodeSessionFactory(
config=OpenCodeConfig(base_url="http://localhost:8000/v1"),
sandbox_backend=Backend(),
create_attempts=3,
create_backoff_s=0,
)
monkeypatch.setattr(factory, "_opencode_already_installed", lambda s: False)
monkeypatch.setattr("time.sleep", lambda delay: None)

with pytest.raises(RuntimeError, match=error_match):
factory.create("write a function")

assert len(sandboxes) == sandbox_attempts
for sandbox in sandboxes:
assert sandbox.killed is True
assert (
len([c for c in sandbox.exec_calls if c != "echo ok"]) == install_attempts
)


class TestBuildInstallCmdVersionPin:
"""The pin must reach the installer, which reads args/VERSION in the bash
side of the ``curl | bash`` pipe — an env prefix on curl never gets there."""

def test_pinned_version_is_passed_as_installer_argument(self):
cmd = build_install_cmd(
OpenCodeConfig(base_url="http://proxy:8000/v1", opencode_version="1.0.180")
)
assert "| bash -s -- --version 1.0.180" in cmd
assert "OPENCODE_VERSION" not in cmd

def test_latest_omits_version_argument(self):
cmd = build_install_cmd(OpenCodeConfig(base_url="http://proxy:8000/v1"))
assert "| bash &&" in cmd
assert "--version 1" not in cmd
Loading