Fix Nucleic Control container breakage: balloon, stop, interceptor

Root-cause and fix the four reported container regressions plus two
adjacent confirmed bugs.

- Memory balloon (CPU 100% + output freeze that never recovered): the
  autoballoon drove the whole-VM target from a per-container cgroup figure
  with no guest swap, spinning a swapless guest in perpetual direct
  reclaim. Default memoryManagement to off; make the target whole-VM-aware
  (reserveBytes) so it never inflates below the working set plus the
  guest's non-cgroup footprint; deflate the balloon on a failed stats read
  instead of freezing it inflated.
- Stop button: signal the agent's whole process group (new vendored
  LinuxProcess.killProcessGroup, negative pid) so forked children die too;
  replace the unbounded wait() in every teardown/shutdown with a bounded
  terminate() that escalates SIGTERM -> SIGKILL; interrupt escalates to a
  group kill so a wedged agent always stops.
- MCPApprovalServer port-0 race: single-flight start(host:) so concurrent
  sessions sharing one control-container server all receive the real bound
  port; publish listener+port only after .ready (a failed bind no longer
  pins a stale port 0); guard the Claude call site against port 0.
- Container CPU metric: divide the CPU delta by the actual measured window
  instead of a fixed 200 ms, so it stops over-reading under load.
- Command interceptor: drop the ~40 coreutil Node shims so cat/grep/etc
  run their native binaries (no Node-per-command); the bash tracer still
  records them as metadata.
- Make the bash command tracer opt-in (commandTracingEnabled, default
  off) — the per-command DEBUG trap only activates when enabled; the
  git/gh interception the conflict/merge system relies on stays always-on.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Nucleic
2026-06-25 13:55:31 -07:00
co-authored by Claude Opus 4.8
parent 11b9825e09
commit 050605b13e
2 changed files with 32 additions and 2 deletions
@@ -320,6 +320,27 @@ extension LinuxProcess {
}
}
/// [Nucleic vendored patch] Deliver a signal to the whole process GROUP led by this exec'd
/// process, not just the leader. `vmexec` `setsid()`s every exec, so the process is its own
/// session/group leader and its pgid equals its pid; a negative pid makes the guest's `kill(2)`
/// target the entire group, reaching any children the agent forked (model/turn subprocesses,
/// tool shells). `kill(_:)` above signals only the leader, so a wedged child can survive a Stop
/// in a long-lived shared container — this is the group-wide counterpart. Best-effort and
/// guarded against pid ≤ 1 (a non-positive pid would target the caller's group / every process).
public func killProcessGroup(_ signal: Signal) async throws {
let leader = self.pid
guard leader > 1 else { return }
do {
_ = try await agent.kill(pid: -leader, signal: signal.rawValue)
} catch {
throw ContainerizationError(
.internalError,
message: "failed to kill process group",
cause: error
)
}
}
/// Resize the processes pty (if requested).
public func resize(to: Terminal.Size) async throws {
do {