fix(opensandbox): mark explicit command deadlines as Exit Code: 124 (#6885)

* fix(opensandbox): report the command deadline as Exit Code: 124

`execute_command` had no timeout branch, so a command that hit its deadline
was reported as whatever the SDK happened to surface. OpenSandbox is the last
provider without the `Exit Code: 124` marker that Local/AIO/Tenki/E2B/BoxLite
emit, and which #6847 established the evidence chain parses to recover the
real shell status.

The deadline is enforced by execd, not by the client: the timeout travels to
the server in `RunCommandOpts`, the SSE read timeout is disabled, and the
process group is reaped with SIGKILL. So a timed-out command does not raise --
it comes back as a `CommandExecError` result with the signal death reported as
exit code -1, which the adapter rendered as `Exit Code: -1`.

Normalize that pairing to the 124 marker, keeping the partial output the
command produced before the kill. The guard is deliberately narrow: a command
that exits non-zero on its own never carries the error event, and a failed
launch shares the same error event with no usable code, so neither is
relabelled a timeout.

Tests: three cases pin the deadline paths (client-side TimeoutError from a
bridge, the server-side -1 kill with output, and the free-text shape that must
stay on the existing missing-exit-code path as a start failure), plus a case
pinning that ordinary exits keep their own code.

RED/GREEN: the deadline cases fail on the unmodified source and pass with it;
test_opensandbox_provider.py 114 passed (110 pre-existing unchanged); the four
sandbox provider suites 601 passed; ruff check and format clean.

* fix(opensandbox): recognize explicit deadline results

Signed-off-by: yetuge <2219677952@qq.com>

---------

Signed-off-by: yetuge <2219677952@qq.com>
This commit is contained in:
yetuge 2026-10-15 11:36:41 +08:00 committed by GitHub
parent c324f351ee
commit ac9ed11b8b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 116 additions and 7 deletions

View File

@ -1,3 +1,11 @@
### Command deadline reporting
Normalize only `CommandExecError` / `context deadline exceeded` with no numeric
exit code to `Exit Code: 124`, preserving partial output. Keep transport errors,
other missing-code errors and numeric exits unchanged. A signal-only `-1` does
not prove a deadline. Tests must use execution results, not a fake-only raised
`TimeoutError`, for the deadline contract.
### OpenSandbox output synchronization
Release mirrors scoped regular output files to `Paths.sandbox_outputs_dir()`

View File

@ -94,7 +94,15 @@ The full DeerFlow surface is implemented:
- `execute_command` forwards per-call environment variables and positive
wall-clock timeouts through `RunCommandOpts`, preserving stdout, stderr, and
non-zero exit information in DeerFlow's string result.
non-zero exit information in DeerFlow's string result. An explicit
`CommandExecError` with value `context deadline exceeded` and no numeric
exit code ends with `Error: command timed out` and `Exit Code: 124`, keeping
any partial output. Transport exceptions and other missing-code errors keep
their existing error reporting. Numeric exits, including signal termination
(`-1`), keep their own code: a signal-only event does not identify whether a
command deadline or another kill caused it. Servers that expose only that
signal status need additional deadline information before it can safely be
normalized to 124.
- Text and binary file operations use OpenSandbox's native filesystem API.
Append is serialized as a read-modify-write because SDK 0.1.x has no append
primitive.

View File

@ -81,11 +81,7 @@ def execution_stdout(execution: Any) -> str:
def format_execution(execution: Any) -> str:
"""Combine stdout, result text, and stderr using DeerFlow's string contract."""
output = execution_stdout(execution)
result = _join_event_text(item.text for item in getattr(execution, "result", []) if getattr(item, "text", None) is not None)
output = _append_output(output, result)
stderr = _join_event_text(message.text for message in getattr(getattr(execution, "logs", None), "stderr", []))
output = _append_output(output, stderr)
output = execution_body(execution)
error = getattr(execution, "error", None)
if error is not None:
detail = f"{getattr(error, 'name', type(error).__name__)}: {getattr(error, 'value', error)}"
@ -93,6 +89,30 @@ def format_execution(execution: Any) -> str:
return output
def execution_body(execution: Any) -> str:
"""Combine the command's own stdout, result text and stderr.
Excludes the error detail so a caller that reports the failure itself (the
timeout branch) can present the partial output without a second, rawer copy
of the same failure underneath it.
"""
output = execution_stdout(execution)
result = _join_event_text(item.text for item in getattr(execution, "result", []) if getattr(item, "text", None) is not None)
output = _append_output(output, result)
stderr = _join_event_text(message.text for message in getattr(getattr(execution, "logs", None), "stderr", []))
return _append_output(output, stderr)
def _is_timeout_execution(execution: Any, exit_code: int | None) -> bool:
"""Recognize an explicit command deadline, not an ambiguous signal kill.
The SDK leaves exit_code unset when the error value is nonnumeric. A
bare -1 only indicates signal termination and cannot prove a timeout.
"""
error = getattr(execution, "error", None)
return exit_code is None and getattr(error, "name", None) == "CommandExecError" and getattr(error, "value", None) == "context deadline exceeded"
class OpenSandboxSandbox(Sandbox):
"""Wrap one live ``opensandbox.sync.SandboxSync`` instance."""
@ -254,8 +274,15 @@ class OpenSandboxSandbox(Sandbox):
except Exception as exc:
logger.error("Failed to execute command in OpenSandbox %s: %s", self.id, exc)
return f"Error: {exc}"
output = format_execution(execution)
exit_code = getattr(execution, "exit_code", None)
# Command deadlines arrive as execution error events, not builtin
# TimeoutError. Normalize only the explicit deadline result; transport
# errors, launch failures and signal-only exits keep their own details.
if _is_timeout_execution(execution, exit_code):
body = execution_body(execution)
message = "Error: command timed out\nExit Code: 124"
return f"{body}\n{message}" if body else message
output = format_execution(execution)
if exit_code is None:
detail = output or "no completion or error event"
return f"Error: OpenSandbox command completed without an exit code: {detail}"

View File

@ -1095,6 +1095,72 @@ def test_execute_forwards_env_timeout_and_combines_streams() -> None:
assert box.execute_command("missing-complete") == "Error: OpenSandbox command completed without an exit code: stream ended"
def _command_error_execution(*, error_value: str, exit_code: int | None = None, error_name: str = "CommandExecError", stdout: tuple[str, ...] = (), stderr: tuple[str, ...] = (), result: tuple[str, ...] = ()) -> Any:
execution = _execution(stdout=stdout, stderr=stderr, result=result, exit_code=exit_code)
return types.SimpleNamespace(**vars(execution), error=types.SimpleNamespace(name=error_name, value=error_value))
@pytest.mark.parametrize(
("stdout", "stderr", "result", "expected_body"),
[
((), (), (), ""),
(("partial output",), (), (), "partial output\n"),
(("out-1", "out-2"), ("stderr",), ("result",), "out-1\nout-2\nresult\nstderr\n"),
],
)
def test_execute_command_server_side_deadline_appends_exit_marker(stdout: tuple[str, ...], stderr: tuple[str, ...], result: tuple[str, ...], expected_body: str) -> None:
# The SDK cannot parse this deadline error value as an integer, so it
# returns an Execution with exit_code=None rather than raising TimeoutError.
remote = _FakeRemote("remote")
remote.commands.run = lambda command, opts=None: _command_error_execution(error_value="context deadline exceeded", stdout=stdout, stderr=stderr, result=result) # type: ignore[method-assign]
box = _box(remote, sandbox_timeout=timedelta(seconds=60), default_command_timeout=120)
assert box.execute_command("sleep 9999", timeout=5) == f"{expected_body}Error: command timed out\nExit Code: 124"
@pytest.mark.parametrize("error_value", ["credential lookup failed", "context canceled", "signal: killed", "timeout while starting process", ""])
def test_execute_command_other_missing_exit_codes_are_not_timeouts(error_value: str) -> None:
remote = _FakeRemote("remote")
remote.commands.run = lambda command, opts=None: _command_error_execution(error_value=error_value) # type: ignore[method-assign]
box = _box(remote, sandbox_timeout=timedelta(seconds=60), default_command_timeout=120)
assert box.execute_command("true", timeout=5) == f"Error: OpenSandbox command completed without an exit code: CommandExecError: {error_value}"
@pytest.mark.parametrize("exit_code", [-1, 7, 124])
def test_execute_command_numeric_error_events_keep_their_exit_code(exit_code: int) -> None:
# execd sends ordinary nonzero exits through CommandExecError too. In
# particular, -1 only proves signal termination, not a command deadline.
remote = _FakeRemote("remote")
remote.commands.run = lambda command, opts=None: _command_error_execution(error_value=str(exit_code), exit_code=exit_code, stdout=("partial output",)) # type: ignore[method-assign]
box = _box(remote, sandbox_timeout=timedelta(seconds=60), default_command_timeout=120)
assert box.execute_command("exit-or-signal", timeout=5) == f"partial output\nCommandExecError: {exit_code}\nExit Code: {exit_code}"
@pytest.mark.parametrize("exit_code", [None, 7])
def test_execute_command_deadline_text_on_other_errors_is_not_a_command_timeout(exit_code: int | None) -> None:
remote = _FakeRemote("remote")
remote.commands.run = lambda command, opts=None: _command_error_execution(error_value="context deadline exceeded", exit_code=exit_code, error_name="OtherError") # type: ignore[method-assign]
box = _box(remote, sandbox_timeout=timedelta(seconds=60), default_command_timeout=120)
result = box.execute_command("true", timeout=5)
assert "OtherError: context deadline exceeded" in result
assert "command timed out" not in result
assert not result.endswith("Exit Code: 124")
@pytest.mark.parametrize("message", ["", "context deadline exceeded"])
def test_execute_command_transport_exception_is_not_a_shell_timeout(message: str) -> None:
remote = _FakeRemote("remote")
remote.command_error = TimeoutError(message)
box = _box(remote, sandbox_timeout=timedelta(seconds=60), default_command_timeout=120)
# Even an injected builtin TimeoutError must not turn a provider/renewal
# failure into an authoritative shell exit. The real SDK wraps this error.
assert box.execute_command("true", timeout=5) == f"Error: {message}"
def test_operations_renew_remote_lifetime_and_bound_default_commands() -> None:
remote = _FakeRemote("remote")
remote.file_data["/mnt/user-data/workspace/note.txt"] = b"note"