feat(sandbox): detached PTY exec — run long commands without holding a connection - #2558
Conversation
…a connection pty.exec(detach=True) starts the command in a session, drops the WebSocket, and briefly re-attaches every poll_interval_s to check for completion, so a long-running command occupies a connection for milliseconds per poll instead of its whole runtime. At eval scale this replaces thousands of hours-held sockets with a handful of short-lived polls, and structurally avoids the failure modes long-lived connections are exposed to (load-balancer idle timeouts, server deploys). Output is captured to files inside the sandbox — the server retains only ~1 MiB of terminal output across a detach — and collected when the command finishes, so stdout and stderr come back separated in both pty and pipe modes. Without a session a private one is opened and closed; an explicitly passed session is detached while the command works and is attached and reusable again on return. Mechanically: OpenSandboxPtySession gains detach()/reattach() built on the existing resume machinery (takeover + since=<bytes received>); a detached session is not treated as closed, so provider pruning leaves it alone, and close() still releases and ends it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Hemil Desai <hemild@nvidia.com>
E2E validation: 500-rollout SWE-bench Verified (Qwen3.6-27B), agent command detachedQuality — parity with attached mode. Pass rate 71.8% (329/458; run stopped at the ≥450 sufficiency marker), vs 69.2–70.2% for the attached-PTY baselines and 67.7–70.0% for the exec-path baselines on the same setup. Zero Connections — ~10× fewer held. NLB
Net of background: roughly ~170 vs ~1,600 run-attributable standing connections. The create/install burst and the verify-phase grading burst show as short transients; steady state holds. Integration in the eval was 3 lines: the agent's long opencode command runs 🤖 Generated with Claude Code |
|
/ok to test 8697184 |
|
/claude review |
|
SHIP WITH CARE Focused, well-reasoned addition of a detached-exec mode to the sandbox PTY ( One correctness concern worth resolving before this feeds real output back to callers (inline on
Minor, author's call: on a timeout mid-poll an explicitly-passed session is left detached rather than reattached, which contradicts the docstring's "attached and reusable again when this returns" — but since the timeout result flags |
…o sandbox filesystem writes Reworked after review. The detached machinery moves out of the public api module into the session that owns the transport: OpenSandboxPtySession.run_detached() writes the marker-wrapped command, drops the socket, and re-attaches every poll to drain output from the server's retained window. Nothing is written to the sandbox filesystem (the previous file-capture design could fill a tmpfs-backed /tmp), and a new replay-gap counter turns window overflow into a loud error instead of silently truncated output — bulk-output commands belong on attached exec or the exec API's background mode. exec(detach=True) is now a thin dispatch: session lifecycle, the same per-sandbox serialization as attached execs, timeout mapping, and a reattach so an explicitly passed session always comes back attached — including on timeout. detach() refuses sessions whose pump already ended (a dead session must not dodge provider pruning), and reads on a detached session fail fast instead of blocking. Output is one merged stream (replay carries no channel split), stderr is None, matching the PTY-mode exec contract. Validated live against the deployed endpoint: 40s command over 8s polls returns complete merged output with the session reusable after, and a 3 MB burst produced while detached raises the designed retained-window error. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Hemil Desai <hemild@nvidia.com>
Redesign in
|
|
/ok to test 3653629 |
E2E re-validation of the replay-window design (
|
|
/claude review |
| await self.write( | ||
| f"{{ {command}\n}} </dev/null\nprintf '%s%s:%s\\n' '{token[:5]}' '{token[5:]}' \"$?\"\n".encode() | ||
| ) | ||
| buffer = bytearray() |
There was a problem hiding this comment.
RISK — stale _replay_gap can spuriously fail a reused session's detached run.
_replay_gap is a monotonic per-session accumulator (pty.py:151) that is never reset. run_detached checks if self._replay_gap: (pty.py:370) to detect output evicted during this command, but the counter also gets bumped by the ordinary connection-loss recovery path: _reattach_socket re-dials with since=self._received, and if the server evicted bytes during the outage, _pump_socket increments _replay_gap.
What breaks: an explicitly-passed live session that survived one socket drop with eviction (exactly the case _pump/_reattach_socket exist to handle in the proxy-shedding environment) carries a nonzero _replay_gap into a later pty.exec(..., detach=True). The very first poll's drain then raises "PTY output exceeded the server's retained window" even though this command lost nothing.
Blast radius: a false-negative hard failure of an otherwise-successful detached command on a reused session — a spuriously failed eval/training step, not silent corruption (it raises).
Fix: reset the window at the start of run_detached so it only measures loss for the current command, e.g. set self._replay_gap = 0 right after writing the launch line (before the poll loop at pty.py:357).
|
SHIP WITH CARE — internal sandbox PTY transport addition ( Scope check. Adds a detached exec mode: launch a marker-delimited command, drop the WebSocket, and re-attach every One RISK, posted inline ( Nits I'll leave to your judgment (not blocking): the launch-line Nothing else material. Good tests — the eviction and stdin-EOF cases are real assertions, not pass-throughs. |
|
/ok to test 53ffec2 |
What
pty.exec(detach=True)runs a command without holding a connection while it works: the command starts in a PTY session, the WebSocket is dropped, and the session is briefly re-attached everypoll_interval_sto check for completion. A long-running command occupies a connection for milliseconds per poll instead of its whole runtime.Why
At eval scale, session-mode PTY holds one WebSocket per rollout for the rollout's lifetime — thousands of standing connections held for hours. Detached exec replaces that with a handful of short-lived polls (~200 ms each), a ~100x reduction in standing connections, and structurally avoids the failure modes long-lived sockets are exposed to (load-balancer idle timeouts on quiet sessions, server deploys interrupting streams).
How
OpenSandboxPtySession.detach()/reattach(): built on the existing resume machinery (takeover=1,since=<bytes received>). execd sessions run fine with no client attached — the socket is a view, not the session's lifeline. A detached session is notclosed, so provider pruning leaves it alone;close()still releases and ends it.pty.exec(..., detach=True, poll_interval_s=...): same marker discipline as session-mode exec, plus file capture inside the sandbox (>cap.out 2>cap.err) because the server retains only ~1 MiB of terminal output across a detach. Output iscat-collected on completion, so stdout/stderr come back separated in both pty and pipe modes. A fast command that finishes within the first quiet window never detaches at all.sessiona private session is opened (never registered as the default-shell session) and closed afterwards. An explicitly passed session is detached while the command works and comes back attached and reusable.Testing
since/takeoveron re-dial, prune safety, close-after-detach) and facade-level detached exec (poll cycle, fast path, private-session lifecycle, timeout parity withexec()). 107 tests passing intest_opensandbox_pty.py+test_sandbox.py.🤖 Generated with Claude Code