Repository navigation
feat(python): build Substrate driver and runtime directly on Client a… - #80
Conversation
e24006c to
b33b984
Compare
Tomer Glottmann (tomergee)
left a comment
There was a problem hiding this comment.
Review of PR 1 at b33b984. I ran the 116 unit tests (pass), read the driver, the runtime, the loop runner, the fleet, the handle, the config, the errors, the types and the driver tests, and probed the fleet against the fake stack.
Request changes. The two blocking items from the #75 review are fixed, and most of that list is done. Two items from that list are still open, and three new defects stop a default configuration from working. The items are in order of severity.
Status of the #75 review items
| Item | Status |
|---|---|
1 one-shot asyncio.run facade |
fixed: LoopRunner, one loop thread, run_coroutine_threadsafe |
2 warm pool survives teardown |
fixed: reap_async clears the pool; a test covers it |
| 3 no real template name | partly: FleetConfig.template and Task.template exist; the default path is still wrong (B1) |
| 4 USER_GUIDE examples | fixed: context manager, list acquire / release, setup(tasks), two tests |
| 5 preflight cannot fail | fixed, a test covers it |
| 6 failed deletes hidden | fixed: WARNING, reap raises with the list |
| 7 unused parameters | partly: acquire_timeout_s and auth_token work; ready_timeout_s is cosmetic (M2); step_timeout_s, worker_family, node_selector, tolerations are still unused |
8 exec returns str, __exit__ recycles |
not done (B2) |
| 9 router and per-actor endpoint code | fixed |
| 10 error taxonomy | fixed |
| 11 tests against the fake server | fixed: sync fleet, setup/teardown, preflight |
12 lists run in sh -c |
fixed: start_process for argv |
13 host / port / ip_address |
fixed |
Blocking
B1. fleet.py:272 with backend/substrate.py:158 and types.py:234 — a fleet with no template configured requests a template that does not exist. Probe against the fake stack: FleetConfig(backend="substrate", endpoint=...) plus Task(image="docker.io/library/python@sha256:…") creates with template.name == "tmpl-python@sha256-2176bb31f3eb". That name exists on no cluster, and @ is not valid in a resource name, thus the API rejects it before it looks it up. The quickstart registers default-template; use it as the default. Better, after the rebase onto main: pass rootfs_image=task.image to Client.create and delete template_key, image_templates and _image_to_template; #70 derives the template per image on the server. Also at backend/substrate.py:158: default_template != "default-template" treats the real default name as "unset". Use None for unset.
B2. handle.py:52 and handle.py:139-143 — exec returns str and drops stderr and exit_code when check=False; __exit__ and __aexit__ call recycle() on success, thus a sandbox that ran agent code goes back to the pool without a reset, by default. The #75 review asked for both in PR 1 if it became its own PR. Return ExecResult; release on exit and make recycling explicit.
B3. fleet.py:87 and fleet.py:255 — close() calls teardown() before backend.close(). reap_async now raises InfrastructureError when one delete fails (item 6), thus close() raises and never stops the loop runner or the client. Inside with SandboxFleet(...) the raise also replaces the original exception. Reap in try, close the backend in finally, and re-raise after.
Before merge
M1. runtime/substrate_env_client.py:156-193 — on the client-side timeout (timeout_s + 5 s) the argv path returns timed_out=True and leaves the process running in the guest until the guest watchdog, one hour by default. Process.kill() exists (process.py:356). Kill the process before you return, on the argv path and on the shell path.
M2. backend/substrate.py:212-223 — the "readiness probe" for a claimed warm actor is client.get, and a failure only logs a warning. A warm actor deleted or crashed after it was suspended is handed to the caller, and the first command fails. On NotFoundError drop the id and take the next one or create; on another error give the actor back and raise SandboxStartError. State in the docstring that resume is implicit on first traffic, because ready_timeout_s does not measure readiness.
M3. backend/substrate.py:248 and substrate.py:260 — the create loop classifies failures with substrings: "template" in err_msg matches any message that mentions a template. The client maps the server's statuses to NotFoundError and FailedPreconditionError (errors.py); the e2e in main already asserts FailedPreconditionError for a missing base. Match the types.
M4. loop_runner.py:55-63 — stop() joins for 2 s and then calls loop.close() even when the thread is still running; close() on a running loop raises. Pending tasks are not cancelled, thus the create_task in warm_pool_async(wait=False) (substrate.py:293) is dropped silently. stop() from the runner thread joins itself and deadlocks, which __del__ can trigger. Cancel pending tasks on the loop, run shutdown_asyncgens, close only after the thread exits, and refuse stop() from the loop thread.
M5. fleet.py:272 — config.template wins over config.image_templates. A user who sets both gets the generic template for every image. Per-image mappings must win over the default.
M6. README.md:9 — the client README advertises AsyncSandboxFleet, which is PR 2. Remove it from this PR.
M7. substrate.py:387 and unwarm_pool_async — deletes run one at a time. warm_pool_async uses a semaphore and gather; use the same for reap and unwarm, or a pool of 200 takes minutes to tear down.
Nits
substrate.py:166:self._templatesis written and never read.substrate_env_client.py:219:NotFoundErroris split by the substrings"environment"and"actor"; the server message is the contract here. Use one error, or add a code toNotFoundError.substrate_env_client.py:270: the synchronousstream_processpump is never cancelled when the consumer stops early; wrap the generator intry/finallyand cancel the future.substrate.py:293: keep a reference to the fire-and-forget suspend task and log its exception.config.py:93:step_timeout_sis validated and never read.worker_family,node_selector,tolerationsreach the driver and have no effect on Substrate; placement is the template's. Remove them or say so in the docstring.config.py:71:endpointdefaults tohttp://localhost:7777; the client strips the scheme, but every other address in the repo ishost:port. Use127.0.0.1:7777.__init__.py:63andexceptions.py:43: the deprecated shim is still the source ofSandboxError, and it still exportsbuiltins.TimeoutErrorunder the same name.runtime/base.py:46:write_file_via_execandread_file_via_exechave no callers. Remove them.config.py: the 70-line__init__withobject.__setattr__can be a dataclass with__post_init__;model_dumpandmodel_validateare pydantic names on a non-pydantic class.backend/mock.pystill usestemplate_key; it goes with B1.
What I ran
uv run --extra dev pytest (116 passed); a probe test against fake_stack for three template configurations (default, default-template, rootfs-base); the leftover sweep for the #75 items. Not run: the live smoke on the sandbox cluster. B1 makes a default-config live run fail before it starts, and with an explicit template the live path is the one measured on 2026-10-12.
b33b984 to
95c07d6
Compare
…nd Env Build SubstrateBackendDriver and SubstrateEnvClientRuntime directly on top of Client and Env, eliminating synthetic/mock stubs and aligning with ate-env-api: - SubstrateBackendDriver directly uses Client for environment lifecycle (create, suspend, delete, and reap) and warm pool management. - Runtime communicates directly with in-guest ProcessService and FileSystemService over gRPC, passing x-env-id / x-env-atespace metadata. - Introduce LoopRunner running a dedicated background event loop for synchronous facades (Fleet, SubstrateBackendDriver, SubstrateEnvClientRuntime) to safely share gRPC channels across sequential calls without event loop collisions. - Wire acquire_timeout_s, ready_timeout_s, labels, and auth_token via gRPC bearer metadata interceptor to Client. - Reaping cleans both run-tagged environments and warm pool actors, draining _warm_paused_pool. - Add context manager support (with SandboxFleet) and setup(tasks=None) to Fleet, supporting single and batch acquire/release. - Drop obsolete HTTP router and synthetic grpc data planes, removing router_url, grpc_endpoint, host, port, ip_address, and interactive session stubs. - Unify exception hierarchy in errors.py (SandboxStartError and PreflightError subclass InfrastructureError) and map gRPC status codes cleanly. - Use standard @DataClass(frozen=True) with custom SecretStr in config.py, avoiding external pydantic dependency. - Default command cwd="" to honor the container image's WORKDIR. - Address PR 1 review feedback: - B1: default template resolution to "default-template", None for unset, and support image digests. - B2: return ExecResult from handle.exec and exec_async; handle __exit__ releases sandbox. - B3: wrap close/teardown/reap in try/finally to guarantee backend driver closure. - M1: kill process on client-side timeout in SubstrateEnvClientRuntime. - M2: drop warm actor and claim next on NotFoundError; document implicit resume. - M3: match NotFoundError and FailedPreconditionError exception types. - M4: LoopRunner stop guard against caller thread, cancel tasks and close cleanly. - M5: config.image_templates takes precedence over config.template in fleet. - M6: remove AsyncSandboxFleet mentions from PR 1 README. - M7: parallelize unwarm_pool_async and reap_async with Semaphore(16). - Cleaned unused exec file helpers and templates cache. - Add comprehensive unit tests against fake ate-env-api gRPC server for driver, runtime, and fleet strategies.
95c07d6 to
36f81ca
Compare
Tomer Glottmann (tomergee)
left a comment
There was a problem hiding this comment.
Second pass at 36f81ca. Thank you, this is a different PR: B2, B3, M1 to M7 and most nits are done, the headers are in, and the 118 tests pass. One item from B1 is left, the image pass-through. It is not a blocker for this PR: it goes to PR 2 of the SDK, see below.
Blocking: none.
Before merge
- PR description. The line about
guest_pb2_grpc.pyversion checks describes a change that is no longer in the branch. Remove it, and add a short list of what changed after the first review, so the next reader does not diff two commits to find out.
For PR 2 of the SDK, not a blocker here
fleet.py:213 → backend/substrate.py:247 and :297. The task image does not reach the server. A probe against the fake stack with a default FleetConfig and Task(image="docker.io/library/python@sha256:…") sends template.name == "default-template" and rootfs_image == "". The sandbox runs the guest's own image, not the task's, unless the image is in image_templates. The branch is on main, thus Client.create(rootfs_image=…) is available and the server derives the template per image (#70, see the e2e test gated on ATE_ENV_ROOTFS_IMAGE). Make _ensure_template_for_image return (template, rootfs_image): a mapped image gives (mapped, None); any other image gives (config.template or "default-template", image), where the configured template is the base. Let BackendDriver.acquire and warm_pool take rootfs_image, pass it at both client.create calls, and key _warm_paused_pool by (template, image). Add a test that asserts last_create_request.rootfs_image. This is work for PR 2, not for this PR: the fleet API change (acquire and warm_pool signatures, the pool key) belongs with the async fleet and the strategies. For this PR, one sentence in the Task.image docstring that the image is a lookup key for image_templates until PR 2 is enough.
Nits
loop_runner.py:74-76:loop.close()runs afterjoin(timeout=2.0)also when the thread is still alive, andclose()on a running loop raises. Guard it withif not self._thread.is_alive().config.py:93:step_timeout_sis validated and never read. Remove it, or use it as the defaulttimeout_sofSandboxHandle.exec.backend/__init__.py: one extra blank line at the end of the file.
Verified
| Item | Status at 36f81ca |
|---|---|
| B1 default path | fixed: default-template; None means unset; mappings win over template (M5) |
B2 ExecResult, release on exit |
fixed, test updated |
B3 close() order |
fixed: reap in try, backend in finally, re-raise; __exit__ keeps the original exception |
| M1 kill on client timeout | fixed |
| M2 warm claim | fixed: NotFoundError drops and continues, other errors give back and raise |
| M3 error types | fixed |
| M4 loop runner | fixed except the nit above |
| M6 README | fixed |
| M7 parallel reap and unwarm | fixed, semaphore 16 |
| nits | _templates, suspend task, endpoint default, dead file helpers, stream pump cancel: fixed |
| hand-edited stub from #81 | gone, _gen is identical to main |
What I ran: uv run --extra dev pytest (118 passed); the probe above for the default and the explicit-template configurations.
Tomer Glottmann (tomergee)
left a comment
There was a problem hiding this comment.
Approving at 36f81ca. The three blockers and M1 to M7 from the first review are fixed, CI is green, 118 tests pass. The image pass-through goes to PR 2 of the SDK as agreed; the description refresh can follow there too.
|
Updated PR description. |
490130e
into
agent-substrate:main
The fleet, driver, runtime, strategies, config and their tests arrived after the ruff gate in this PR was written and carry 198 findings under its configuration. All mechanical: PEP 585/604 annotations (list[str], X | None) and typing imports moved to collections.abc, which the 3.10 floor and the future import already allow; unused imports and import order in tests; quoted annotations. Two by hand: backend/mock.py used Any without importing it, and client.py had `import collections` below a function. `uv run --extra dev pytest`: 118 passed. `ruff check clients/python`: clean.
The fleet, driver, runtime, strategies, config and their tests arrived after the ruff gate in this PR was written and carry 198 findings under its configuration. All mechanical: PEP 585/604 annotations (list[str], X | None) and typing imports moved to collections.abc, which the 3.10 floor and the future import already allow; unused imports and import order in tests; quoted annotations. Two by hand: backend/mock.py used Any without importing it, and client.py had `import collections` below a function. `uv run --extra dev pytest`: 118 passed. `ruff check clients/python`: clean.
…#83) - go.mod: mcp go-sdk 1.8.0, grpc 1.84.0, k8s.io 0.37.1. - staticcheck ST1005 in the three CLI error strings; ruff configuration and CI step for clients/python, with the SDK merged in #80 brought under it (mechanical fixes plus a missing Any import and a misplaced import collections). - Finished TODO(jbd) notes removed from env.proto and internal/ate/client.go; docs/release.md says rootfs-image templates. - Python stubs regenerated with grpcio-tools 1.84; both Python projects' grpcio floor moves to 1.84 to match the stamped requirement. - .github/dependabot.yml: weekly grouped updates for the Go module, the Python client and the workflow actions, 7-day cooldown, Substrate module excluded on purpose.
…railing newline (#89) Follow-ups from the #80 second-pass review: - LoopRunner.stop() closes the loop only when the worker thread has exited; close() on a running loop raises. - FleetConfig.step_timeout_s reaches SandboxHandle.default_timeout_s and is the default timeout for exec() and exec_async(); it was validated and never read. - Trailing blank line in backend/__init__.py removed.
Summary
Replaces the previous mock/synthetic driver and runtime stubs with direct implementations built on top of the native Agent Substrate Python SDK (
ClientandEnv).This PR establishes PR 1 of the 3-part SDK overhaul, providing a robust, production-ready foundation for managing Substrate environment lifecycles and in-guest execution.
Key Changes
1. Direct
Client&EnvIntegrationSubstrateBackendDriver:Client(create,suspend,delete,reap)._warm_paused_poolon reap/teardown.acquire_timeout_s,ready_timeout_s, and custom tags/labels.SubstrateEnvClientRuntime:ProcessServiceandFileSystemServiceover gRPC.x-env-idandx-env-atespacegRPC metadata headers.cwd=""to respect the container image's nativeWORKDIR.2. Event Loop Concurrency (
LoopRunner)LoopRunner) for synchronous facades (SandboxFleet,SubstrateBackendDriver,SubstrateEnvClientRuntime).3. Clean Error Hierarchy & Status Mapping
ate_env.errors:SandboxStartErrorandPreflightErrornow inherit fromInfrastructureError.CommandError).DEADLINE_EXCEEDED,UNAVAILABLE,NOT_FOUND).4. Zero External Pydantic Dependency
config.pywith standard library@dataclass(frozen=True)and a lightweightSecretStrimplementation for secure token handling.5. Fleet Lifecycle & Warm Pool
with SandboxFleet(...) as fleet:) ensuring deterministic teardown and reaping.setup(tasks=None)for batch pre-warming sandboxes before rollout iterations.router_url,grpc_endpoint, host/port stubs).6. Hermetic Unit Tests
ate-env-apigRPC server verifying driver lifecycle, runtime execution, and fleet strategies.Tests
Unit tests passed, and tested with the full change in #75