[FEATURE] Background tasks need deadlines and typed cancel handlers — run_in_background has a worker but no clock

Status Open
Reported on v2.1.223
Maintainer reply None cached
Activity 0 comments · opened Aug 15, 2026

Summary

A backgrounded task in Claude Code has no deadline, cannot be given one after launch, and
when it dies its termination event does not identify why. A wedged background task
therefore runs until the session ends, and the only thing that can stop it is the model
choosing to call TaskStop — which requires the model to be awake and paying attention.

This is the one execution path in the system that has a worker but no clock.

The ask in one line: every process runner should carry a timeout and a cancel handler,
where the handler can return a status code, logs, and diagnostic context to the caller. That
is standard practice in enterprise service buses and job queues (Sidekiq, Celery, Temporal,
systemd TimeoutStopSec, Kubernetes activeDeadlineSeconds), and it is the missing half
here.

Environment

  • Claude Code 2.1.223
  • macOS 15.7.5 (Darwin 24.6.0, x86_64)
  • Model: claude-opus-5
  • Bash tool, run_in_background: true

Gap 1 — no deadline available on the background path

Bash accepts a timeout parameter, but it bounds a foreground call: the harness awaits
the child against a timer it holds. Passing run_in_background: true discards that awaiting
code by design, and nothing replaces it. There is no timeout equivalent for a backgrounded
task, and no default.

Both halves of the mechanism already exist in the product:

  • a killer that reaps correctly (see Gap 3 caveat), and
  • timers held outside the model's turn loop — foreground Bash timeout (max 600000 ms)

and Monitor timeout_ms (max 3600000 ms, with persistent: true to opt out).

They are simply never combined on the background path. A backgrounded task is the only unit
of work in the system that can outlive every clock in it.

Gap 2 — no post-launch mutation

Once a task is running there is no API to attach, extend, or shorten a deadline.

  • TaskStop takes exactly one parameter, task_id. Kill now, or nothing.
  • TaskUpdate looks like the answer but operates on the todo list, not on running

processes — subject, status, owner, blockedBy.

If a task is launched unbounded and then hangs, no lever exists that does not require the
model to intervene by hand. (The naming collision between the two "task" concepts is also
noted in #84432.)

Gap 3 — termination events do not identify cause

An external kill does generate an event — the death is not silent. Verified: after kill -9
on a backgrounded task's supervised shell, this arrived unprompted:

<task-id>bqsd4qpyd</task-id>
<status>failed</status>
<summary>Background command "..." failed with exit code 137</summary>

137 is 128 + SIGKILL. That integer is all the model receives, and it is identical across:

  • a deadline being enforced
  • an OOM kill
  • a user killing the process from another terminal
  • a crash that terminated via SIGKILL

The correct follow-up differs completely per cause — raise the limit, reduce memory, read the
stack trace — but the event gives no way to tell them apart.

A killed status does appear to exist elsewhere in the harness (reported in #76249), so the
vocabulary is not empty. But there is no timed_out, and in the case measured here an
external SIGKILL surfaced as the generic failed. Queue systems distinguish timed_out
from failed as a first-class status precisely because the remediation diverges.

Gap 4 — no cancel handler

No hook runs when a task is terminated. Nothing can flush a partial log, capture the process
tree at the moment of death, record which stage the job had reached, or attach a reason
string to the event. Diagnosis is limited to whatever the job already happened to flush to
its output file, with no signal that a deadline was ever involved.

This is the piece with no equivalent anywhere in the current design. The timer primitive at
least exists and is merely unwired; a termination hook does not exist at all.

Why this matters most where it is least visible

The architecture treats the model as the scheduler — noticing a task has run too long and
calling TaskStop is the model's job, not a policy engine's.

That is defensible for interactive use, where the user is present and the model is invoked
constantly. It breaks in exactly the case where a deadline matters most: scheduled,
headless, and unattended runs.
If a background task wedges at 3am with no user present,
nothing wakes the model, so the model never takes a turn, so the deadline it was supposed to
enforce never fires. A control loop only controls while something is driving it.

Users running Claude Code on cron or in CI inherit a system where an unattended hang is
unbounded by construction.

Proposed design

  1. timeout_ms on Bash when run_in_background: true, enforced by the same

externally-held timer that already backs foreground Bash and Monitor. Ideally with a
configurable session default, so that omitting it is not silently unbounded.

  1. A distinct terminal status for deadline enforcementtimed_out, separate from

failed/killed, so the model can branch on cause instead of guessing from an exit code.

  1. Graceful termination: SIGTERM, grace period, then SIGKILL, applied to the process

group. This alone lets well-behaved jobs flush their own diagnostics.

  1. A cancel handler — a command run on termination, receiving the reason

(timeout / stopped / oom), the task id, and elapsed time, with its stdout attached
to the termination event. This is what turns "it died" into "it died at stage 3 after 600s
waiting on the same lock," which is what the calling process actually needs.

  1. Post-launch deadline mutation — extend or shorten a running task's deadline, so a job

that is legitimately slow can be granted more time rather than killed and restarted.

Current workaround, and the trap inside it

With no way to attach a deadline after launch, the only option is baking a watchdog in at
spawn time. On stock macOS there is no timeout(1) and no gtimeout unless coreutils is
installed — both verified absent on this machine — so the reflexive timeout 600 cmd &
silently does not exist. /usr/bin/perl is present, so
perl -e 'alarm shift; exec @ARGV' 600 cmd works.

More importantly, the naive watchdog is worse than the harness at the job it replaces.
Verified — kill -9 on the supervised shell only:

--- simulating watchdog: kill -9 48637 ---
--- survivors ---
48638 sleep 5077

The supervised shell died, the harness correctly reported exit 137, and the actual work kept
running. A sleep N; kill $pid watchdog produces a "failed" event plus a live orphan still
consuming resources — strictly worse than no watchdog at all. A correct hand-rolled watchdog
must setsid the job and kill the process group (kill -- -$PGID).

That this is the trap users will fall into is itself an argument for the harness owning the
deadline.

Note on kill semantics across platforms

On this machine (macOS) TaskStop reaped the full process group correctly. Verified:

# background: bash -c 'echo "SHELL=$$" > pids.txt; sleep 4931 & echo "GRANDCHILD=$!" >> pids.txt; wait'
  PID  PPID  PGID STAT COMMAND
48245 48242 48242 S    bash -c echo "SHELL=$$" ... sleep 4931 & ... wait
48246 48245 48242 S    sleep 4931

# after TaskStop:
--- alive after stop ---   (nothing)
--- survivors by pattern --- (none)

This is not universal. #85200 reports TaskStop on Windows/Git Bash leaving the child
alive (an orphaned rm that kept deleting for ~20 minutes), while #76249 describes the tree
terminating correctly on Windows/PowerShell. So reap behavior appears to vary by platform and
shell backend.

That divergence strengthens the case rather than weakening it: if the harness owns deadlines
and termination, this is one implementation to get right once, instead of every user
hand-rolling a watchdog against a kill semantic that differs per platform.

Related issues

  • #85200 — TaskStop not killing the process tree (Windows/Git Bash)
  • #76249 — background task killed with status killed without a TaskStop
  • #84432 — no tool to list live background tasks; also notes the TaskList/task-id confusion

None of these requests deadlines or a cancel handler, which is what this issue asks for.

Reproduction

  1. Launch any long-running command with run_in_background: true.
  2. Observe there is no parameter to bound its runtime.
  3. Kill it externally; observe the termination event reports only exit code 137.
  4. Observe no mechanism exists to have caused that kill on a schedule without the model

explicitly calling TaskStop.

View original on GitHub ↗