[BUG] Background Bash task's process group is SIGKILLed mid-run when the command spawns a daemonizing CLI (cursor-agent) — and reported as completed (exit code 0)

Status Open
Reported on v2.1.233
Maintainer reply None cached
Activity 4 comments · opened Aug 16, 2026

Summary

When a Bash tool call with run_in_background: true invokes a CLI that spawns a detached daemon (observed with Cursor's cursor-agent CLI, invoked as agent -p ...), the entire background task process group — the task's shell and the CLI itself — is SIGKILLed mid-run with high probability (7 out of 9 runs in my testing). All output produced after the kill point is lost; often the captured output file is 0 bytes.

The dangerous part: the task notification still reports completed (exit code 0), so neither the model nor the user can detect the failure from the exit status. The model happily reads an empty output file from a "successful" task.

Foreground Bash calls running the exact same command are unaffected (3/3 success).

Environment

  • Claude Code 2.1.233 (CLI, terminal)
  • macOS 26.6.1 (Darwin 25.6.0), Apple Silicon
  • Trigger CLI: cursor-agent 2026.08.11-e8db854 (Node-based; spawns a detached worker-server daemon on first use)

Reproduction

  1. Have Claude run a Bash tool call with run_in_background: true whose command invokes agent -p --output-format json ... "some prompt" (cursor-agent CLI, authenticated).
  2. Wait for the completion notification (arrives after the CLI's normal duration, reporting exit code 0).
  3. Read the task output file: it is empty or truncated at the point the group was killed, even though the same command in a foreground Bash call prints a complete JSON response.

Evidence that it is a SIGKILL of the whole group, delivered by the background-task machinery

I isolated this step by step (each bullet is an actual run):

  • echo before; agent ... >/dev/null 2>&1; echo after (background): before is captured, after is lost, and the harness's own [exited with code N] trailer is missing. Filesystem side-effect markers (touch) placed after the CLI call are never created → the task shell itself dies while waiting on the CLI, not just the pipe.
  • Same run shape with trap handlers for TERM/HUP/INT/PIPE logging to a file: no trap ever fires, no "survived" line → the shell dies from an uncatchable signal (SIGKILL).
  • Redirecting everything to my own files (agent ... > out.json 2> err.log) does not reliably help: in one run out.json stayed 0 bytes — the CLI itself was killed before writing its result. So the kill hits the whole process group, not just writers to the captured pipe.
  • Generic pipe-holders do not trigger it: echo before; sleep 600 & echo after (background) captures both lines, completes immediately, no kill. So this is not ordinary "child still holds the pipe" handling.
  • Fast-failing runs of the same CLI (unauthenticated → immediate auth error) are captured fine, including stderr. Only runs where the CLI actually executes get killed.
  • Outside the background-task machinery the kill never happens: the same command works 3/3 in foreground Bash calls, and also works when a foreground Bash call backgrounds it manually (zsh -c 'agent ... > out.json 2>&1; touch done' & + polling): complete JSON, inner shell survives.
  • The kill is probabilistic: 2 of 9 background runs completed normally (one even captured zsh's post-command time-report and the [exited with code N] trailer). The other 7 were killed mid-run.
  • The CLI leaves a detached worker-server daemon (setsid, reparented to PID 1, fds on /dev/null). My working hypothesis is that the background-task manager reacts badly to something about this daemonization (or the CLI's process management) and SIGKILLs the task's process group — but the exit-code-0 report suggests the kill path isn't surfacing the real wait status either way.

Expected behavior

  • The background task's process group should not be SIGKILLed while the main command is still running; and
  • if the harness does kill (or observe the death of) the task, the notification should report the real termination status (e.g. killed by SIGKILL / exit 137), not exit code 0, so the failure is detectable.

Actual behavior

Process group SIGKILLed mid-run (~78% of runs with this CLI); captured output truncated or empty; task reported as completed (exit code 0).

View original on GitHub ↗

3 Comments

deemwario · 15 days ago

The exit code 0 on a killed group is the part I'd argue makes this a correctness bug rather than a UX one, and it's worth separating from the SIGKILL itself — they'll likely need different fixes.

An agent consuming that notification has no way to distinguish "task finished, output empty because there was nothing to say" from "task was killed, output empty because it never got written". It reads a 0-byte file from a task the harness explicitly reported as successful, and proceeds on it. The failure then surfaces somewhere much later and looks like a model mistake rather than a lost task.

One thing I don't think is on your list, from hitting a different trigger of the same shape (a Go HTTP server started with & inside a background Bash call, macOS, no cursor-agent involved — mine died when the parent call hit its timeout, so it is NOT your spontaneous-kill repro and I don't want to muddy it): moving the child out of the task's process group survives it. setsid, or nohup … & disown, means a group-wide signal can't reach the child. In my case that took a process that reliably died at the parent's exit to one that outlived several subsequent calls. Worth a run on your 9-run harness — if the CLI survives under setsid while the task shell still dies, that isolates the kill to the group rather than to anything about the CLI's own daemonization, which would narrow your hypothesis usefully.

The defensive pattern that has held up for me regardless of cause: don't let a task's exit status be the proof it worked. Have the command write a sentinel as its own last act (… && touch .done) and treat a missing sentinel as failure even on exit 0 — the sentinel is created by the work, so it can't be forged by the reporting layer. You're already using touch markers diagnostically here; the same trick works as a permanent guard, and it's cheap enough to leave in.

Happy to run the setsid variant against a daemonizing CLI on my side if a second data point on different hardware would help.

28peso · 15 days ago

Ran the setsid variant you suggested — it discriminates exactly the way you hoped.

Setup: same background Bash task shape, but the CLI is detached into its own session before exec, with output going straight to a file:

perl -MPOSIX -e 'POSIX::setsid()==-1 and die; exec @ARGV' -- \
  agent -p --output-format json ... > out.json 2>&1
echo "post rc=$?"; touch done-marker

(macOS has no setsid(1), hence the perl shim.)

Results (3 trials):

| trial | task shell (stays in task's pgroup) | CLI (own session via setsid) |
|---|---|---|
| 1 | SIGKILLed (no done-marker, capture truncated) | survived, wrote complete valid JSON to the file after the shell was already dead |
| 2 | survived | completed |
| 3 | survived | completed |

Trial 1 is the discriminating case: the shell died mid-run exactly as before, but the CLI in its own session was untouched and finished writing its response (validated as parseable JSON with the expected result field). So the kill does not reach outside the process group — it's a group-targeted SIGKILL, not a tree walk over descendants. That also fits the earlier negative result that plain pipe-holding children (sleep 600 &) never trigger anything: whatever fires the kill is specific to this CLI's startup, and my current best guess is the harness's cleanup misreading the daemonization dance (double-fork/setsid of the worker-server causes rapid membership churn in the group) as "task finished, reap the group" — which would also explain both the ~20% survival rate (it's a race) and the exit-0 report (cleanup path believes it's normal completion). That last part is speculation; the group-targeting is measured.

Practical fallback that follows from this: setsid + file output works as a background-safe pattern for the payload (3/3 complete JSON), though the task shell itself can still die, so no post-processing after the CLI call can be relied on — write the result to a file and read it from a later call. Your sentinel-file point stands regardless; with the shell killable at any point, the sentinel has to be written by the detached process itself to be meaningful.

Agreed on splitting the exit-0 report into its own concern — it's what turns a flaky kill into silent data corruption downstream.

deemwario · 14 days ago

Trial 1 is a lovely result — one run where the two halves diverge is worth more than twenty where they agree, and it converts "group-targeted" from a hypothesis into a measurement.

You also corrected my sentinel advice, and the correction matters more than the original: if the task shell can die at any point, a sentinel written by that shell is exactly as untrustworthy as the exit code — it only proves the shell survived long enough to touch a file. The sentinel has to be written by the detached process, as part of the work, or it is just a second unreliable narrator. I stated that too loosely.

The generalisation I'd draw, since this is not really about cursor-agent: for any long-running work, the completion signal and the artifact must come from the same process. Anything where the orchestrator reports completion on behalf of the worker can report success for work that never happened. In practice that means the orchestrator polls for an artifact the worker wrote, and the worker's own last act writes it — which is the shape you've landed on.

One falsifiable test of your churn hypothesis, if you want to push it further: if the trigger is the harness's cleanup racing rapid group-membership churn during the double-fork, then delaying the daemonization should move the survival rate rather than leave it flat. Something like wrapping the CLI so the worker-server spawn happens a second or two after the task shell settles, or conversely forcing the churn earlier. If survival stays ~20% regardless of when the churn happens, the race is with something else (task start, first output, first pipe write) and that narrows it usefully in the other direction. Either result is informative, which is the nice property.

Worth noting the two bugs really are separable and probably want separate fixes: the SIGKILL is a lifecycle bug, but the exit-0 is a reporting bug that would still be dangerous even if the kill were legitimate — a harness reaping a group it believes is finished should report what it did, not code 0.

Showing cached comments. Read the full discussion on GitHub ↗