Skip to content

feat(py): exec-shaped backend seam for the execution worker - #244

Draft
jat255 wants to merge 4 commits into
mainfrom
jat255/m6-f11e-exec-backend
Draft

feat(py): exec-shaped backend seam for the execution worker#244
jat255 wants to merge 4 commits into
mainfrom
jat255/m6-f11e-exec-backend

Conversation

@jat255

@jat255 jat255 commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

First piece of M6's execution tool: the single call the driver will use to run the worker process, so that hosting the worker somewhere else later is a new implementation rather than a rewrite of the driver.

Summary

ExecBackend is the interface (exec(cmd, input, cwd, env, timeout)) and LocalBackend runs the worker as a child of this process with no isolation. Nothing consumes it yet; the worker, the protocol, and the sandbox are separate units of work.

Three behaviours here are easy to get wrong, and each is pinned by a test. Input goes in on stdin rather than as an argument. Output past the cap keeps the tail and lets the process finish, because killing on the cap would discard a result the code had already computed, and not reading would deadlock the child against a full pipe. Shutdown escalates from SIGTERM to SIGKILL with bounded waits, and runs on timeout and on cancellation alike.

env replaces the parent's environment rather than extending it. The allowlist that decides what belongs in it is a separate unit of work; this is only the mechanism that makes an allowlist possible.

Review notes

The cancellation path is what deserves scrutiny. Shutdown runs as its own shielded task, with the backend holding a reference to it, so a caller that cancels twice cannot leave a SIGTERM-ignoring child alive. The reasoning is in a comment at the handler.

Tests drive real subprocesses. The one stand-in is for the missed-exit race, which cannot be provoked on demand.

Sandboxing is not in this PR, and is no longer deferred either. D3 was reversed, so M6 brings the worker sandbox to parity with pkg-r. That is tracked as separate work and does not change anything here.

Testing

uv run pytest (116 passed), uv run ruff check, and uv run pyrefly check are clean in pkg-py/. Every commit was reviewed by roborev; two findings came back (the timeout not covering process exit, and a second cancellation aborting the shutdown escalation) and both were reproduced by a failing test before being fixed.

The first piece of M6 (kata f11e). Everything the execution driver needs
from a process host is one call, so hosting the worker somewhere else --
a Connect container, most likely -- becomes another implementation of
ExecBackend rather than an edit to the driver above it.

Three behaviours here are the ones Inspect's subprocess utilities got
right and are easy to get wrong:

Input goes in on stdin, never as an argument, so there is no escaping to
mishandle and no command-line length limit. stdin is written without a
drain: a worker that dies during startup leaves nobody reading the pipe,
and that is a failed call to report rather than a BrokenPipeError out of
the plumbing.

Output past the cap keeps the tail and lets the process finish. Killing
on the cap would discard a result the code had already computed, and
simply not reading would deadlock the child against a full pipe. The
head is what gets dropped, since the result is usually last.

Shutdown escalates rather than going straight to SIGKILL, so a worker
that handles SIGTERM gets to clean up. After SIGKILL the wait is bounded:
the child watcher can miss an exit, and a killed process is gone whether
or not we observe it go.

env replaces the parent's environment rather than extending it. The
allowlist that decides what belongs in it is a separate task; this is
only the mechanism that makes an allowlist possible at all.

Tests drive real subprocesses. The one stand-in is for the missed-exit
race, which cannot be provoked on demand.
Draining stdout and stderr ends at end-of-file, which a process can reach
while still running: closing both streams and carrying on defeated the
deadline entirely, and the call then waited on process.wait() with no
bound at all.

Both halves now sit inside the caller's deadline, so neither can outlast
it. Found by review of 48934b2.
The driver cancels calls when a conversation goes away or the agent shuts
down, and only the timeout path was ending the process. A cancelled call
left the worker running: still holding the parent's file descriptors,
still burning CPU, with nobody waiting on the result.

Cancellation now goes through the same shutdown escalation as a timeout.
The waiting happens inside an except block, where an await can be cut
short, so there is a test covering a child that ignores SIGTERM to pin
that SIGKILL still lands there.
The cleanup added in 650302f did its waiting inline in the except block,
so a cancel landing during the SIGTERM grace period cut it short before
SIGKILL and a child ignoring SIGTERM survived. One cancel was covered;
two were not.

Shutdown now runs as its own task, awaited through a shield, with the
backend holding a reference so it cannot be collected mid-escalation. A
second cancel stops us waiting on it, not the escalation itself.

Found by review of 650302f.
@jat255
jat255 marked this pull request as draft September 2, 2026 00:18
@jat255 jat255 added the py Affects the Python implementation label Sep 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

py Affects the Python implementation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant