Background
Follow-up split from #590 and documented in docs/adr/0019-completion-timeout-for-hanging-process.md.
The #590 fix introduces a completion timeout: once the agent emits the completion signal, Sandcastle waits one minute and, if the process is still silent (a hanging process — typically a spawned gh/git child or a long-lived MCP server holding the sandbox exec's stdout open), it force-completes the iteration successfully and abandons the process.
For container-based sandbox providers this is fine — container teardown (docker rm -f, src/sandboxes/docker.ts:238) kills the abandoned process. But for the no-sandbox provider, close() is a no-op (src/sandboxes/no-sandbox.ts:150-152) and there is no proc.kill() anywhere in the codebase, so the abandoned agent process and the child holding the fd leak on the host.
This leak already exists on the timeout/abort paths, but the #590 fix promotes it from a rare edge case to the common success path for no-sandbox users.
Why it's non-trivial
proc.kill() only reaps the direct sh -c child, not the grandchild holding the pipe. Reaping the whole tree means either:
- spawning detached (
detached: true) and signalling the process group via process.kill(-pid), or
- a tree-kill approach,
plus tracking the active exec proc on the no-sandbox handle (not tracked today). Both carry their own failure modes — signal handling and detached-process semantics differ on Windows, which is a supported host.
Scope
- Track the active exec process(es) on the no-sandbox handle.
- On
close() (and ideally on the completion-timeout/abort/idle paths), reap the process tree, not just the direct child.
- Verify behaviour on Windows hosts.
Out of scope
The #590 completion-timeout behaviour itself — that ships independently and benefits all providers.
Background
Follow-up split from #590 and documented in
docs/adr/0019-completion-timeout-for-hanging-process.md.The #590 fix introduces a completion timeout: once the agent emits the completion signal, Sandcastle waits one minute and, if the process is still silent (a hanging process — typically a spawned
gh/git child or a long-lived MCP server holding the sandbox exec's stdout open), it force-completes the iteration successfully and abandons the process.For container-based sandbox providers this is fine — container teardown (
docker rm -f,src/sandboxes/docker.ts:238) kills the abandoned process. But for the no-sandbox provider,close()is a no-op (src/sandboxes/no-sandbox.ts:150-152) and there is noproc.kill()anywhere in the codebase, so the abandoned agent process and the child holding the fd leak on the host.This leak already exists on the timeout/abort paths, but the #590 fix promotes it from a rare edge case to the common success path for no-sandbox users.
Why it's non-trivial
proc.kill()only reaps the directsh -cchild, not the grandchild holding the pipe. Reaping the whole tree means either:detached: true) and signalling the process group viaprocess.kill(-pid), orplus tracking the active exec proc on the no-sandbox handle (not tracked today). Both carry their own failure modes — signal handling and detached-process semantics differ on Windows, which is a supported host.
Scope
close()(and ideally on the completion-timeout/abort/idle paths), reap the process tree, not just the direct child.Out of scope
The #590 completion-timeout behaviour itself — that ships independently and benefits all providers.