mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-28 14:10:37 +00:00
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:
parent
4e9f6c68ea
commit
6c6fc8378e
@ -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.
|
||||
|
||||
|
||||
@ -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}"
|
||||
|
||||
@ -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
@ -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, ...]] = []
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user