`Execute` SIGKILLs every command when `ps` is unavailable: `factory-execute-supervisor` treats a failed liveness probe as owner death · Issue #33 · Factory-AI/factory · GitHub
Skip to content

Execute SIGKILLs every command when ps is unavailable: factory-execute-supervisor treats a failed liveness probe as owner death #33

Open

Description

@joshwilhelmi

Summary

In any environment where ps cannot be executed, 100% of Execute tool calls fail instantly with:

Error: Command terminated by signal: SIGKILL

The command itself is never at fault: trivial commands (pwd, /bin/echo hi) die in 57–88 ms. Every other tool (Read, Edit, Grep, MCP tools, fire-and-forget) works normally in the same session.

The cause is factory-execute-supervisor: its owner-liveness probe shells out to ps, and it cannot distinguish "the probe failed" from "my owner died." When the probe fails it concludes the owner is gone and SIGKILLs the whole process group, which contains the command it was supervising.

Environment

  • Droid CLI: 0.219.0 (also reported on 0.218.0, see Related)
  • Host: macOS 27.0 / arm64, droid exec --auto high, non-interactive, running under a seatbelt (sandbox-exec) profile
  • Independently reproduced on Ubuntu 24.04 + Docker (linked issue)

Root cause

droid spawns every Execute command through an inline bash supervisor (createUnixProcess):

Kdc(shell, ["-c", epf, "factory-execute-supervisor", String(process.pid), shell, cmd, ...args],
    { ..., detached: true })

detached: true makes the supervisor a process-group leader, and the command shell runs in that group. The supervisor script is:

owner_is_alive() {
  parent_pid=$(ps -o ppid= -p "$$" 2>/dev/null) || return 1
  parent_pid=${parent_pid//[[:space:]]/}
  [[ "$parent_pid" == "$owner_pid" ]] && kill -0 "$owner_pid" 2>/dev/null
}

watch_owner() {
  while owner_is_alive; do
    sleep 2
  done
  kill -KILL -- -$$ 2>/dev/null || true
}

Two properties combine into the failure:

  1. A failed probe is read as owner death. ps ... || return 1 fires when ps is missing, non-executable, or denied. None of those mean the owner exited.
  2. No grace period. The sleep 2 is inside the loop body, so the first failed probe falls straight through to kill -KILL -- -$$. The kill lands within milliseconds, before the command can produce output.

Because the supervisor leads the process group, kill -KILL -- -$$ takes down the command shell with it, surfacing as SIGKILL to the user.

Why fire-and-forget is unaffected

The fire-and-forget branch spawns exec nohup bash -c '…' directly, with no supervisor wrapper. It is the only shell path that survives, which matches the symptom pattern reported in both environments.

Reproduction

The supervisor logic can be exercised on its own, with no sandbox and no container. Save the script above as epf.sh (with the trailing owner_pid=$1 … exit "$status" lines from the binary), then:

# The supervisor must be its own process-group leader, matching droid's detached: true.

# ps reachable -> works
setsid bash epf.sh $$ /bin/bash 'echo SUPERVISOR_OK'
# -> SUPERVISOR_OK, exit 0   (measured: 7 ms)

# ps unreachable -> command is SIGKILLed
env PATH=/nonexistent setsid bash epf.sh $$ /bin/bash '/bin/echo SUPERVISOR_OK'
# -> no output, exit 137 (SIGKILL)   (measured: 5 ms)

End-to-end, in a container without procps:

docker run --rm -it debian:12-slim   # ships no `ps`
# install droid, then:
droid exec --auto high "use a shell command to print hello"
# -> Error: Command terminated by signal: SIGKILL

Affected environments

Any environment where ps is absent or not executable:

  • Slim/distroless containers without procps (the linked Docker report).
  • Sandboxed execution on macOS. /bin/ps is setuid-root (-rwsr-xr-x), and a seatbelt (sandbox-exec) profile refuses to exec setuid binaries even under (allow default): ps exits 126 (Operation not permitted) while every non-setuid binary runs fine. The denial is the kernel's setuid-exec rule, not a filesystem allowlist, so the sandbox cannot grant it without allowing setuid-root exec. Droid's Execute tool therefore cannot function under a restrictive macOS sandbox at all.

Observed on this machine: 19 of 19 Execute calls SIGKILLed in sandboxed sessions, versus 0 SIGKILLs across 1,131 Execute calls in unsandboxed sessions.

Suggested fix

Given the blast radius (SIGKILL of the entire process group), the probe should fail open, not closed.

Preferred: distinguish probe failure from owner death. When ps itself fails, treat the result as unknown and continue the loop instead of killing. A probe that cannot run says nothing about the owner:

owner_is_alive() {
  parent_pid=$(ps -o ppid= -p "$$" 2>/dev/null) || return 0   # probe unavailable: assume alive
  parent_pid=${parent_pid//[[:space:]]/}
  [[ "$parent_pid" == "$owner_pid" ]] && kill -0 "$owner_pid" 2>/dev/null
}

Alternatives:

  • Fall back to kill -0 "$owner_pid" alone when ps is unavailable. This still detects owner death; it only loses the PID-reuse guard that the parent-identity check provides.
  • Require N consecutive negative results, and move sleep so the first iteration is never immediately fatal.

One note on a tempting shortcut: replacing the ps probe with bash's $PPID does not preserve the current semantics. Bash fixes $PPID at shell startup, so after the owner dies and the shell is reparented, $PPID still equals owner_pid while ps -o ppid= reports the new parent. Detecting that reparenting is the only thing the ps call buys; a $PPID comparison reduces to kill -0 "$owner_pid" and should be presented as such.

Related

Activity

  1. slamdunk111 commented on Sep 25, 2026

    @slamdunk111

    Independent confirmation on a third environment, plus new evidence. Still reproduces on CLI 0.226.1/0.226.2 (auto-updated mid-session from 0.223.0), so this is still live ~6 weeks after filing.

    Environment (this report)

    • Droid CLI 0.223.0 → auto-updated to 0.226.1 → 0.226.2 on 2026-09-25; symptom present on ALL versions
    • Bare-metal Linux (not Docker): Ubuntu 24.04.4 LTS, kernel 7.0.0-31-generic (OEM), AMD Ryzen AI Max+ 395 (gfx1151), 128 GB RAM
    • Surfaces: droid exec --input-format stream-jsonrpc (headless worker) and tui, under tmux 3.4

    New data points

    1. Persists across OS reboot — foreground Execute SIGKILLs before and after a full reboot (07:22 reboot, symptom unchanged after 07:24 boot). Rules out transient host state.
    2. Random race confirmed with twin probes: two identical foreground echo invocations — one exits 0, one is SIGKILLed. Externals (/bin/true, uname -r, python3 --version) die ~always within ~1s; late in the day even builtins get killed after producing output. Suggests the probe failure itself is racy in our environment, not a clean deterministic first-probe failure.
    3. Same-root corroboration from the daemon itself: the daemon's BackgroundProcessTracker hits the identical wall on this host — uncaught exception EACCES: permission denied, posix_spawn 'ps' (tree-kill force-kill path) — plus repeated WARN [ResourceMonitor] Failed to sample process tree RSS | reason: no_trusted_ps_executable. So ps is unavailable to multiple CLI subsystems here, and the supervisor is the one that reacts lethally.
    4. Kernel-level causes excluded on this host (bare-metal, so complementing the macOS/Docker reports): /proc/self/status → Seccomp: 0, NoNewPrivs: 0; kern.log/syslog zero entries at kill timestamps; ulimits clean; OOM impossible (128 GB, load ~1). User-side PreToolUse hooks ruled out (pass-through stubs; a deny produces a denial message, not SIGKILL).
    5. Operational workaround, verified across versions and reboot: route every command through fire-and-forget background execution → output lands in /tmp/droid-bg-*.out → read the file via the harness Read tool. With this pattern the session is fully functional (including journalctl, python, etc.); foreground is the only casualty.

    Ask (echoing the OP, with emphasis)

    Beyond fixing the supervisor (failed probe ≠ owner death; add grace period), please consider letting the CLI's own subsystems (supervisor liveness probe, ResourceMonitor, BackgroundProcessTracker/tree-kill) use a ps that works inside your own sandbox — three independent subsystems degrade on the same masked binary, and only the supervisor's failure mode is destructive.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions