Skip to content

Commit 9e3589b

Browse files
fix(tenki): upgrade sdk and preserve timeout failures (#6162)
* fix(tenki): upgrade sdk and preserve timeout failures * fix(tenki): tolerate noisy warm-pool health probes * docs(tenki): compact sandbox guidance within chain budget --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
1 parent 8cd7aef commit 9e3589b

8 files changed

Lines changed: 177 additions & 11 deletions

File tree

‎README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -625,6 +625,12 @@ Remote directory listings report traversal failures (for example, unreadable
625625
directories) as incomplete results, even when no entries were returned. A
626626
missing start path is reported separately as “Directory not found.”
627627

628+
The optional [Tenki cloud sandbox provider](backend/packages/harness/deerflow/community/tenki/README.md)
629+
uses Tenki SDK 1.4.0 or newer. Timed-out commands preserve partial output and
630+
report `Exit Code: 124`; unsuccessful health checks cannot reclaim a warm sandbox.
631+
Health probes tolerate login-shell output around the `ok` line, and failures log
632+
the sandbox ID and probe output before replacing the sandbox.
633+
628634
#### MCP Server
629635

630636
In the chat UI, enable **Token Usage → Debug** to inspect generic/MCP tool calls.

‎backend/packages/harness/deerflow/community/tenki/README.md‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ Install the optional SDK before selecting this provider:
3737
pip install "deerflow-harness[tenki]"
3838
```
3939

40-
The `tenki` package (which provides the `tenki_sandbox` module) is an optional
40+
The `tenki>=1.4.0` package (which provides the `tenki_sandbox` module) is an optional
4141
DeerFlow harness extra, not part of the default install. Get an API key from
4242
<https://tenki.cloud/docs/sandbox/sdk>.
4343

@@ -67,6 +67,9 @@ API; directory and content search shell out and reuse `deerflow.sandbox.search`,
6767
mirroring `e2b_sandbox`:
6868

6969
- `execute_command` — `sh -lc`, with per-call env and timeout.
70+
Timed-out commands preserve partial output and report `Error: command timed out`
71+
with `Exit Code: 124`, even if the SDK returned exit code zero. They are not
72+
retried, and the sandbox remains available for subsequent commands.
7073
- `read_file` / `write_file` / `update_file` — native `fs.read_text` / `fs.mkdir` / `fs.write_stream` (binary-safe, streamed).
7174
- `download_file` — native `fs.read_stream`, restricted to the `/mnt/user-data` prefix; the 100 MB cap is enforced on bytes actually received, so a file growing mid-transfer cannot slip past it.
7275
- `list_dir` / `glob` / `grep` — `find` / `grep` with busybox-portable flags (the fs API is single-level and has no content search); results filtered/capped in Python and reported back under `/mnt/user-data`.
@@ -83,6 +86,11 @@ sandboxes. `sandbox.idle_timeout` controls how long released warm sandboxes stay
8386
running; `0` disables idle reaping. Active sandboxes are never evicted to satisfy
8487
the cap.
8588

89+
The warm-pool health probe accepts an exact `ok` line alongside login-shell
90+
profile output or stderr warnings. Timeout, nonzero-exit, and command-error
91+
diagnostics still prevent reuse. Failed probes log the sandbox ID and returned
92+
output before the unhealthy microVM is terminated and replaced.
93+
8694
## Scope: stable features only
8795

8896
Only the stable Tenki surface is used — sandbox create/terminate plus

‎backend/packages/harness/deerflow/community/tenki/provider.py‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -330,11 +330,12 @@ def _create_sandbox(self, sandbox_id: str) -> TenkiSandbox:
330330
# Best-effort: on failure the file APIs still work via the home remap.
331331
try:
332332
result = remote.exec("sh", "-lc", _bootstrap_script(self._config["home_dir"]), timeout=_BOOTSTRAP_TIMEOUT)
333-
if result.exit_code not in (0, None) or "BOOTSTRAP_OK" not in (result.stdout_text or ""):
333+
if result.timed_out or result.exit_code not in (0, None) or "BOOTSTRAP_OK" not in (result.stdout_text or ""):
334334
logger.warning(
335-
"Tenki bootstrap for %s exited code=%s stderr=%s",
335+
"Tenki bootstrap for %s exited code=%s timed_out=%s stderr=%s",
336336
sandbox_id,
337337
result.exit_code,
338+
result.timed_out,
338339
(result.stderr_text or "").strip(),
339340
)
340341
except Exception as e:
@@ -407,7 +408,12 @@ def _reclaim_warm_pool(self, sandbox_id: str) -> str | None:
407408

408409
try:
409410
result = sandbox.execute_command("echo ok", timeout=10)
410-
healthy = "ok" in result
411+
# Login-shell profiles can add stdout/stderr around the probe marker.
412+
# Keep rejecting adapter failure diagnostics even if "ok" was printed.
413+
lines = result.splitlines()
414+
healthy = "ok" in lines and not any(line.startswith(("Error:", "Exit Code:")) for line in lines)
415+
if not healthy:
416+
logger.warning("Tenki warm-pool sandbox %s health check failed: %s", sandbox_id, result)
411417
except Exception as e:
412418
logger.warning("Tenki warm-pool sandbox %s health check error: %s", sandbox_id, e)
413419
healthy = False

‎backend/packages/harness/deerflow/community/tenki/sandbox.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,9 @@ def execute_command(
274274
output = f"{stdout}\n{stderr}"
275275
else:
276276
output = stdout or stderr
277+
if result.timed_out:
278+
message = "Error: command timed out\nExit Code: 124"
279+
return f"{output}\n{message}" if output else message
277280
if result.exit_code not in (0, None):
278281
# Mirror LocalSandbox: preserve a nonzero exit in the output text
279282
# even when the command produced output (see e2b_sandbox).

‎backend/packages/harness/deerflow/sandbox/AGENTS.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,8 @@ cancellation before propagating it.
9292
- `BoxliteProvider` (`packages/harness/deerflow/community/boxlite/`) - optional BoxLite micro-VM isolation. Loop-affine handles live on one private daemon-thread asyncio loop; sync `Sandbox` calls marshal through `run_coroutine_threadsafe`.
9393
Boxes use deterministic `user_id:thread_id` IDs, park in a same-owner warm pool, and are reclaimed only by that owner. Health-check timeouts reach both BoxLite `exec(timeout=...)` and private-loop `.result(timeout)`; a bridge timeout must cancel its coroutine so work cannot continue or retain acquire ownership in the background.
9494
`sandbox.replicas` caps active + warm VMs per gateway process; if capacity is exhausted, only warm-pool VMs are evicted. `sandbox.idle_timeout` stops idle warm VMs after the configured seconds. `reset()` is intentionally a lightweight registry clear for `reset_sandbox_provider()` and does not close boxes, stop the idle reaper, or close the private loop; full teardown remains `shutdown()`.
95-
- `TenkiSandboxProvider` (`packages/harness/deerflow/community/tenki/`) - Tenki cloud microVM isolation. The `tenki` SDK is optional (`deerflow-harness[tenki]`, importable as `tenki_sandbox`) and lazy-imported (`_import_client`) only when this provider is selected. Unlike Boxlite, the SDK is synchronous, so the adapter calls it directly with no event-loop bridge. File transport uses Tenki's native `sandbox.fs` API (`read_text`/`read_stream`/`write_stream`/`mkdir`/`stat`) — binary-safe and streaming, no base64/shell hop; only directory/content *search* (`list_dir`/`glob`/`grep`) shells out to busybox-portable `find`/`grep`, parsed with the shared `deerflow.sandbox.search` helpers like `community/e2b_sandbox`. Sandboxes run as the unprivileged `tenki` user, so DeerFlow's `/mnt/user-data` prefix is remapped under a writable HOME (`_resolve_path`) and best-effort `sudo`-symlinked at bootstrap. Boxes are named deterministically from `sha256(user_id:thread_id)[:16]` (64-bit, matching E2B; the warm pool is keyed by this id alone with no full-seed fallback), released into an in-process warm pool, and reclaimed only by the same user/thread after a liveness check. A terminal session error (named SDK errors plus builtin `ConnectionError`/`BrokenPipeError`/`EOFError`) routes through `_invalidate_sandbox` to evict the dead microVM. Cross-process orphan reconciliation is a follow-up (single-process warm pool today).
95+
- `TenkiSandboxProvider` (`community/tenki/`): cloud microVMs. Optional `tenki` SDK (`deerflow-harness[tenki]`, module `tenki_sandbox`) loads via `_import_client` only on provider selection; calls are synchronous, without an event-loop bridge. Native `sandbox.fs` (`read_text`/`read_stream`/`write_stream`/`mkdir`/`stat`) streams binary-safe files without shell/base64. Only search (`list_dir`/`glob`/`grep`) uses busybox-portable `find`/`grep` and shared `deerflow.sandbox.search` parsing, like E2B. The unprivileged `tenki` user needs `/mnt/user-data` remapped to writable HOME (`_resolve_path`), with a best-effort bootstrap `sudo` symlink. Deterministic names and warm-pool keys use `sha256(user_id:thread_id)[:16]` (64-bit, matching E2B), with no full-seed fallback. Release parks VMs in-process; only the same user/thread reclaims them after a health check. Named terminal SDK errors and builtin `ConnectionError`/`BrokenPipeError`/`EOFError` evict via `_invalidate_sandbox`. Cross-process orphan reconciliation remains a follow-up.
96+
Health probes require an exact `ok` line and reject `Error:`/`Exit Code:` diagnostic lines; other login-shell stdout/stderr is tolerated. Failures log sandbox id and output before eviction.
9697

9798

9899
**Shared warm-pool lifecycle:** community sandbox providers that keep released sandboxes alive for fast reuse share `deerflow.community.warm_pool_lifecycle.WarmPoolLifecycleMixin`. The mixin owns the common `DEFAULT_IDLE_TIMEOUT=600`, `IDLE_CHECK_INTERVAL=60`, `DEFAULT_REPLICAS=3`, idle-checker loop, warm-pool expiry, oldest-warm eviction, replica counting, and soft-cap logging. Providers remain responsible for their own active registries, creation/discovery, health checks, and destroy hook (`_destroy_warm_entry`): AIO destroys `SandboxInfo` through its backend; Boxlite closes loop-affine `BoxliteBox` handles; Tenki closes the microVM session (`TenkiSandbox.close`, which terminates the remote sandbox). AIO keeps active-idle cleanup outside the mixin and delegates only warm-pool expiry to the shared helper.

‎backend/packages/harness/pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ boxlite = ["boxlite>=0.9.7"]
9595
# install stays free of the Tenki SDK; only pulled in when the provider is used.
9696
# The distribution is ``tenki``; it still ships the ``tenki_sandbox`` module that
9797
# provider.py and sandbox.py import.
98-
tenki = ["tenki>=1.0.0"]
98+
tenki = ["tenki>=1.4.0"]
9999
# OpenSandbox remote sandbox provider (deerflow.community.opensandbox). The
100100
# sync SDK is loaded only when this provider is selected.
101101
opensandbox = ["opensandbox>=0.1.15,<0.2.0"]

‎backend/tests/test_tenki_provider.py‎

Lines changed: 143 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,11 @@
3131

3232

3333
class _FakeResult:
34-
def __init__(self, exit_code: int = 0, stdout: bytes = b"", stderr: bytes = b"") -> None:
34+
def __init__(self, exit_code: int = 0, stdout: bytes = b"", stderr: bytes = b"", timed_out: bool = False) -> None:
3535
self.exit_code = exit_code
3636
self.stdout = stdout
3737
self.stderr = stderr
38+
self.timed_out = timed_out
3839

3940
@property
4041
def stdout_text(self) -> str:
@@ -388,6 +389,26 @@ def test_execute_command_returns_error_as_text() -> None:
388389
assert box.execute_command("echo hi") == "Error: boom"
389390

390391

392+
@pytest.mark.parametrize("exit_code", [0, 143])
393+
@pytest.mark.parametrize("stdout,stderr", [(b"partial output", b"diagnostic"), (b"", b"")])
394+
def test_execute_command_reports_timeout_and_preserves_output(monkeypatch, exit_code, stdout, stderr) -> None:
395+
fake = _FakeSandbox()
396+
monkeypatch.setattr(fake, "_run_script", lambda script: _FakeResult(exit_code=exit_code, stdout=stdout, stderr=stderr, timed_out=True))
397+
invalidated = []
398+
box = TenkiSandbox("sb", fake, on_terminal_failure=lambda *args: invalidated.append(args))
399+
400+
output = box.execute_command("sleep 10", timeout=1)
401+
402+
assert "Error: command timed out" in output
403+
assert output.endswith("Exit Code: 124")
404+
assert stdout.decode() in output
405+
assert stderr.decode() in output
406+
assert len(fake.exec_calls) == 1
407+
assert fake.exec_calls[0]["timeout"] == 1
408+
assert not invalidated
409+
assert not box.is_closed
410+
411+
391412
def test_execute_command_closed_returns_error() -> None:
392413
box = TenkiSandbox("sb", _FakeSandbox())
393414
box.close()
@@ -845,6 +866,127 @@ def test_bootstrap_is_non_interactive_and_time_bounded(monkeypatch):
845866
provider.shutdown()
846867

847868

869+
def test_timed_out_bootstrap_warns_even_with_success_marker(monkeypatch, caplog):
870+
class _TimedOutBootstrap(_FakeSandbox):
871+
def _run_script(self, script: str) -> _FakeResult:
872+
if "BOOTSTRAP_OK" in script:
873+
return _FakeResult(stdout=b"BOOTSTRAP_OK\n", timed_out=True)
874+
return super()._run_script(script)
875+
876+
provider = _install(monkeypatch, client=_FakeClient(sandbox_factory=_TimedOutBootstrap))
877+
try:
878+
with caplog.at_level("WARNING"):
879+
sid = provider.acquire("thread-1", user_id="u1")
880+
assert provider.get(sid) is not None
881+
assert any("bootstrap" in r.message and "timed_out=True" in r.message for r in caplog.records)
882+
finally:
883+
provider.shutdown()
884+
885+
886+
@pytest.mark.parametrize("timed_out,exit_code", [(True, 0), (False, 1)])
887+
def test_warm_pool_rejects_unsuccessful_probe_with_ok_output(monkeypatch, caplog, timed_out, exit_code):
888+
client = _FakeClient()
889+
provider = _install(monkeypatch, client=client)
890+
try:
891+
sid = provider.acquire("thread-1", user_id="u1")
892+
first = client.last_sandbox
893+
provider.release(sid)
894+
monkeypatch.setattr(
895+
first,
896+
"_run_script",
897+
lambda script: _FakeResult(stdout=b"ok\n", exit_code=exit_code, timed_out=timed_out),
898+
)
899+
900+
with caplog.at_level(logging.WARNING):
901+
assert provider.acquire("thread-1", user_id="u1") == sid
902+
assert client.last_sandbox is not first
903+
assert first.closed
904+
failure = next(record.message for record in caplog.records if "health check failed" in record.message)
905+
assert sid in failure
906+
assert "ok\n" in failure
907+
assert f"Exit Code: {124 if timed_out else exit_code}" in failure
908+
finally:
909+
provider.shutdown()
910+
911+
912+
@pytest.mark.parametrize(
913+
"stdout,stderr",
914+
[
915+
(b"ok\n", b"sh: warning: setlocale: LC_ALL: cannot change locale\n"),
916+
(b"profile loaded\nok\n", b""),
917+
(
918+
b"profile loaded\nok\n",
919+
b"sh: warning: setlocale: LC_ALL: cannot change locale\n",
920+
),
921+
],
922+
)
923+
def test_warm_pool_reclaims_healthy_probe_with_shell_noise(monkeypatch, caplog, stdout, stderr):
924+
client = _FakeClient()
925+
provider = _install(monkeypatch, client=client)
926+
try:
927+
sid = provider.acquire("thread-1", user_id="u1")
928+
first = client.last_sandbox
929+
provider.release(sid)
930+
monkeypatch.setattr(
931+
first,
932+
"_run_script",
933+
lambda script: _FakeResult(stdout=stdout, stderr=stderr),
934+
)
935+
calls_before = len(first.exec_calls)
936+
937+
with caplog.at_level(logging.WARNING):
938+
assert provider.acquire("thread-1", user_id="u1") == sid
939+
assert client.create_count == 1
940+
assert client.last_sandbox is first
941+
assert not first.closed
942+
assert len(first.exec_calls) == calls_before + 1
943+
assert first.exec_calls[-1]["argv"] == ("sh", "-lc", "echo ok")
944+
assert first.exec_calls[-1]["timeout"] == 10
945+
assert not any("health check" in record.message for record in caplog.records)
946+
finally:
947+
provider.shutdown()
948+
949+
950+
@pytest.mark.parametrize("stdout", [b"not ok\n", b"ok-ish\n", b""])
951+
def test_warm_pool_logs_probe_without_exact_success_line(monkeypatch, caplog, stdout):
952+
client = _FakeClient()
953+
provider = _install(monkeypatch, client=client)
954+
try:
955+
sid = provider.acquire("thread-1", user_id="u1")
956+
first = client.last_sandbox
957+
provider.release(sid)
958+
monkeypatch.setattr(first, "_run_script", lambda script: _FakeResult(stdout=stdout))
959+
960+
with caplog.at_level(logging.WARNING):
961+
assert provider.acquire("thread-1", user_id="u1") == sid
962+
assert client.create_count == 2
963+
assert client.last_sandbox is not first
964+
assert first.closed
965+
output = stdout.decode() if stdout else "(no output)"
966+
assert any(f"Tenki warm-pool sandbox {sid} health check failed: {output}" == record.message for record in caplog.records)
967+
finally:
968+
provider.shutdown()
969+
970+
971+
def test_warm_pool_rejects_probe_error_containing_success_line(monkeypatch, caplog):
972+
client = _FakeClient()
973+
provider = _install(monkeypatch, client=client)
974+
try:
975+
sid = provider.acquire("thread-1", user_id="u1")
976+
first = client.last_sandbox
977+
provider.release(sid)
978+
first.exec_error = RuntimeError("probe transport failed\nok")
979+
980+
with caplog.at_level(logging.WARNING):
981+
assert provider.acquire("thread-1", user_id="u1") == sid
982+
assert client.create_count == 2
983+
assert client.last_sandbox is not first
984+
assert first.closed
985+
assert any(f"Tenki warm-pool sandbox {sid} health check failed: Error: probe transport failed\nok" == record.message for record in caplog.records)
986+
finally:
987+
provider.shutdown()
988+
989+
848990
def test_release_parks_in_warm_pool(monkeypatch):
849991
client = _FakeClient()
850992
provider = _install(monkeypatch, client=client)

‎backend/uv.lock‎

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)