fix(sandbox): append the Exit Code: 124 marker on E2B/BoxLite bash timeouts (#6848)

* fix(sandbox): append the Exit Code: 124 marker on E2B/BoxLite bash timeouts

When a bash command exceeds its timeout on the E2B or BoxLite providers,
the SDK raises (TimeoutException / TimeoutError) and the generic except
returned a bare "Error: <message>" with no exit marker; a bridge-future
timeout on BoxLite stringifies to an empty message, so the model saw just
"Error: ". Local, AIO, and Tenki all end timed-out output with the
"Exit Code: 124" marker that _truncate_bash_output preserves and the
acceptance-checklist harvest parses to recover the real shell status.

Catch the timeout exceptions ahead of the generic handler in both
providers and return the Tenki-style "Error: command timed out" +
"Exit Code: 124" ending. With #6782 forwarding bash_command_timeout to
every provider, this path is now a first-class configured behavior.

OpenSandbox routes timeouts through the same generic except, but its SDK
is an optional extra whose timeout behavior I could not verify locally,
so it is left out of this change.

Fixes #6847

Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>

* test(boxlite): clarify bridge timeout coverage and SDK failure limits

---------

Signed-off-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
Yufeng He 2026-10-14 13:50:19 +08:00 committed by GitHub
parent 4e9f6c68ea
commit 6c6fc8378e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 76 additions and 2 deletions

View File

@ -66,6 +66,13 @@ inside the box and reuse `deerflow.sandbox.search`, mirroring `e2b_sandbox`:
- `download_file` — 100 MB cap, restricted to the `/mnt/user-data` prefix.
- `list_dir` / `glob` / `grep` — `find` / `grep` with busybox-portable flags; results filtered/capped in Python.
Command timeouts pass the same deadline to the SDK and event-loop bridge. A
bridge deadline returns `Error: command timed out` followed by `Exit Code: 124`.
BoxLite's `SimpleBox.exec` converts SDK wait failures to `ExecResult(exit_code=-1)`;
if that result arrives before the bridge deadline, DeerFlow preserves `-1`.
The bridge handler catches Python's builtin `TimeoutError`, which is distinct
from the SDK's `boxlite.TimeoutError`.
The provider creates `/mnt/user-data/{workspace,uploads,outputs}` and
`/mnt/skills` on box start so those virtual paths resolve natively.

View File

@ -190,6 +190,8 @@ class BoxliteBox(Sandbox):
command timeout inside the VM, and the event-loop bridge receives the
same value so ``run_coroutine_threadsafe(...).result(timeout)`` cannot
block the caller forever if the SDK future itself never resolves.
The bridge deadline produces exit code 124. SDK wait failures returned
as ``ExecResult(exit_code=-1)`` retain -1 if they reach us first.
"""
_validate_extra_env(env) # POSIX env-var key rule; raises ValueError on a bad key
if self.is_closed:
@ -197,6 +199,12 @@ class BoxliteBox(Sandbox):
merged_env = {**self._default_env, **(env or {})} or None
try:
result = self._exec("sh", "-lc", command, env=merged_env, timeout=timeout)
except TimeoutError:
# The loop bridge raises builtin TimeoutError on its deadline.
# SimpleBox.exec converts SDK wait failures to exit_code=-1;
# those returned results use the nonzero-exit branch below.
# Match Local/AIO/Tenki's marker for the bridge timeout outcome.
return "Error: command timed out\nExit Code: 124"
except Exception as e:
logger.error("Failed to execute command in BoxLite box %s: %s", self.id, e)
return f"Error: {e}"

View File

@ -7,7 +7,7 @@ import shlex
import threading
from typing import TYPE_CHECKING
from e2b import CommandExitException, FileNotFoundException
from e2b import CommandExitException, FileNotFoundException, TimeoutException
from e2b_code_interpreter import Sandbox as E2BClientSandbox
from deerflow.config.paths import VIRTUAL_PATH_PREFIX
@ -188,6 +188,11 @@ class E2BSandbox(Sandbox):
# actual shell status.
output = f"{output}\nExit Code: {exit_code}" if output else f"Command exited with code {exit_code}"
return output if output else "(no output)"
except TimeoutException:
# A command deadline is a shell outcome, not a provider hiccup:
# end with the same Exit Code: 124 marker Local/AIO/Tenki emit
# on timeout so evidence consumers recover the real status.
return "Error: command timed out\nExit Code: 124"
except Exception as e:
if _is_sandbox_gone_error(e):
self._dead = True

File diff suppressed because one or more lines are too long

View File

@ -187,6 +187,44 @@ def test_execute_command_appends_exit_marker_when_failure_has_output(monkeypatch
assert box.execute_command("make test") == "5 passed, 1 error\n\nExit Code: 1"
def test_execute_command_bridge_timeout_appends_exit_marker() -> None:
"""The real loop-bridge deadline produces exit code 124 (#6847)."""
from deerflow.community.boxlite.provider import _EventLoopThread
started, cancelled = threading.Event(), threading.Event()
terminal_failures = []
class SlowBox:
async def exec(self, *argv, env=None, timeout=None):
started.set()
try:
await asyncio.Event().wait()
finally:
cancelled.set()
loop_thread = _EventLoopThread()
box = BoxliteBox("box-id", box=SlowBox(), run=loop_thread.run, on_terminal_failure=lambda *args: terminal_failures.append(args))
try:
assert box.execute_command("sleep 9999", timeout=0.05) == "Error: command timed out\nExit Code: 124"
assert started.wait(1)
assert cancelled.wait(1), "bridge timeout must cancel its submitted coroutine"
assert terminal_failures == []
finally:
loop_thread.close()
@pytest.mark.parametrize("error_message", [None, "execution timed out"])
def test_execute_command_preserves_sdk_wait_failure_exit_code(error_message: str | None) -> None:
"""SDK-returned failure results are distinct from bridge TimeoutError."""
class FailedBox:
async def exec(self, *argv, env=None, timeout=None):
return types.SimpleNamespace(exit_code=-1, stdout="partial output", stderr="", error_message=error_message)
box = BoxliteBox("box-id", box=FailedBox(), run=_fake_run)
assert box.execute_command("sleep 9999", timeout=5) == "partial output\nExit Code: -1"
def test_read_file_supports_optional_line_ranges(monkeypatch: pytest.MonkeyPatch) -> None:
calls: list[tuple[str, ...]] = []

View File

@ -1839,6 +1839,22 @@ def test_execute_command_does_not_mark_dead_on_unrelated_error():
assert sb.is_dead is False
def test_execute_command_timeout_appends_exit_marker():
"""A command deadline is a shell outcome, not a provider hiccup: the result
must end with the same ``Exit Code: 124`` marker Local/AIO/Tenki emit on
timeout, so the acceptance-checklist harvest recovers the real status
(#6847)."""
def slow(_cmd: str, **kwargs) -> Any:
raise TimeoutException("deadline exceeded")
client = FakeClient(commands=FakeCommandsAPI([slow]))
sb = _make_sandbox(client)
out = sb.execute_command("sleep 9999", timeout=5)
assert out == "Error: command timed out\nExit Code: 124"
assert sb.is_dead is False
def test_execute_command_forwards_env_and_timeout_to_commands_run():
"""execute_command(env=..., timeout=...) routes env as ``envs`` and the
timeout through to ``commands.run`` so request-scoped secrets (#3861) reach