Background tasks SIGTERMed on an exact 30-minute internal timer (macOS CLI, long-lived session) — undocumented kill path, exit 144

Status Open
Reported on v2.1.221
Maintainer reply None cached
Activity 9 comments · opened Aug 8, 2026

Summary

In a long-lived macOS CLI session, tracked background Bash tasks (launched via the Bash tool with run_in_background: true) are SIGTERMed by the engine on an exact 30-minute internal timer — kills logged at 06:33:04 and 07:03:04 CEST, precisely 1800.000s apart — with no TaskStop call, no session exit, no compaction, no user action. At the instant of each kill, the engine spawns caffeinate -i -t 300 as a direct child. This kill path appears to be undocumented.

Environment

  • Claude Code CLI 2.1.221, macOS (Darwin 25.5.0, Apple Silicon)
  • Long-lived engine process: same claude PID running for 9 days (no restarts across the kills — verified via ps lstart ancestry walk from inside the session)
  • remoteControlAtStartup: true, custom statusLine (ccstatusline), OAuth (Keychain credentials)
  • The background task is a small /bin/sh script that polls a remote host over ssh and exits on a condition or timeout (a "doorbell" that wakes the session when a file appears). It normally runs 30–60+ min and exits 0.

Evidence

Signal-trap wrapper around the task logged its own death and dumped a process snapshot at the instant of each SIGTERM:

2026-08-08 06:10:20 pid=92644 armed
2026-08-08 06:33:04 pid=92644 SIGTERM        <- kill 1
2026-08-08 07:00:21 pid=3017  armed
2026-08-08 07:03:04 pid=3017  SIGTERM        <- kill 2, exactly 1800s after kill 1

The ps snapshot taken inside the SIGTERM trap shows the engine spawning a fresh keep-awake child at the exact kill instant, both times:

PID   PPID  STARTED                   COMMAND
3774  84248 Sat Aug  8 07:03:04 2026  caffeinate -i -t 300   <- born at the kill instant
84248 53157 Thu Jul 30 06:34:50 2026  claude                 <- the engine, 9 days old

Earlier the same night, two more tasks died the same way at ~05:03 and ~05:33 (empty output, reported "stopped"; the ~05:33 one had a secondary heartbeat trace that pinpoints death within seconds of 05:33:04) — consistent with the same :03:04/:33:04 grid.

Additional observations:

  • Not every tick kills: one task ran straight through the 06:03 tick to a clean natural exit; another ran through a tick window earlier in the night unharmed. Whatever the periodic work is, killing tracked tasks is conditional.
  • Exit code: when the harness reports a SIGTERMed background task as failed rather than stopped, it reports exit code 144 (verified by killing a task deliberately with SIGTERM and comparing).
  • Ruled out on the host (checked before suspecting the engine): cron, LaunchAgents, other shells (no matching history/logins), competing processes (pgrep immediately after each kill found no candidate), and the session's own tools (no TaskStop in the transcript).
  • Control data from a Linux session (same scripts, CLI on a container): dozens of identically-armed tasks over three days, zero kills — including tasks that survived a full context compaction and two model switches. The behavior appears specific to the macOS deployment.

Expected behavior

Tracked background tasks should only terminate on: their own exit, TaskStop, explicit user stop, or session exit. If periodic internal maintenance (registry sync? telemetry rollup? conditional compaction pass?) needs to reap them, that seems like a bug — or at minimum should be documented and controllable.

Possibly related

  • #25188 (compaction kills background tasks), #22882 — same kill path symptom, but no compaction occurred here
  • #62202 (Desktop/VS Code exact-interval SIGTERM watchdog at 300s) — precedent for timer-driven kills; different interval and this is the terminal CLI
  • #64522 / #28046 (caffeinate spawn/leak) — confirms the keep-awake child is engine-spawned; here its spawn time doubles as a timestamp of the periodic work

Ask

  1. What runs on the CLI's 30-minute internal timer, and under what conditions does it terminate tracked background tasks?
  2. If the reaping is intentional, document it and consider sparing tasks the harness itself is tracking (the session is woken by the kill notification, re-arms the task, and carries on — so the reap achieves nothing but noise).

Happy to provide more trap logs — the wrapper is still armed and logging every tick outcome.

---
Investigated and written from inside the affected session. 🤖 Generated with Claude Code

View original on GitHub ↗

5 Comments

jroxenhed · 23 days ago

Follow-up data: two more kills since filing, both on the same grid — 07:33:05 and 08:03:04 CEST. Full trap-confirmed sequence is now 06:33:04 → 07:03:04 → 07:33:05 → 08:03:04: four SIGTERMs of tracked background tasks at 30-minute intervals within ±1s, same long-lived engine process throughout (no restarts, no compaction, no TaskStop). Ticks continue to be conditional — tasks armed shortly before some earlier ticks (04:33, 06:03) survived them. The signal-trap wrapper is still logging; happy to keep posting grid points or run added instrumentation if useful.

jroxenhed · 21 days ago

Update: the timer survived an engine upgrade to 2.1.226 — and its grid phase persisted across the restart.

New data point (macOS 25.5.0, Claude Code 2.1.226):

  • Engine process started 03:04:58 local (claude --continue), replacing the 11-day 2.1.221 process from the original report.
  • At 04:03:06 it SIGTERMed a tracked background task (our traced waiter, pid 118, armed 03:45:17 — age 17m49s at death), with the identical signature from the original report: caffeinate -i -t 300 spawned as a direct child of the engine pid at the kill instant (trap log + ps snapshot).

Three inferences:

  1. Not fixed between 2.1.221 and 2.1.226. We'd earlier observed clean boundary crossings on the new engine and hoped otherwise; that was survivorship bias — most of those waiters completed (on inotify-style events) before reaching a boundary.
  2. Not process-age-based. The killed task was 17m49s old, and kills land on the same wall-clock :03/:33 (±1s) grid as before.
  3. The grid anchor is outside the engine process. A 30-minute timer started at engine launch (03:04:58) would fire at :04:58/:34:58. It fired at :03:06 — the same phase as the previous engine's grid. Whatever anchors the schedule persists across engine restarts (state file? external daemon? OS timer coalescing bucket?).

Windows control (Git Bash, same version family): two boundary crossings tonight, both clean, harness exit 0 — so this may be platform-specific or load-dependent. An idle-hour Windows control is still running; we'll post that bracket when it reports.

Happy to run instrumented reproductions if useful — the trap wrapper (SIGTERM handler + process-table snapshot at death) is cheap to keep armed.

jroxenhed · 21 days ago

Two additions: a paired same-instant observation across platforms, and a file-system fingerprint of the maintenance pass that kills.

1. Paired observation at the same boundary. We run identical tracked background waiters on macOS and Windows (Git Bash) in the same fleet. At the 04:03:06 tick reported above:

| platform | armed | age at 04:03:06 | outcome |
|---|---|---|---|
| macOS (2.1.226) | 03:45:17 | 17m49s | SIGTERM at 04:03:06 exactly |
| Windows | 03:42:45 | 20m21s | alive through the boundary; exited clean (exit 0) 2m16s later on its expected event |

Same wall-clock instant, comparable ages either side (the Windows task was older and lived), same fleet, same script family. This removes the compare-across-the-night weakness of our earlier clean-crossing reports: the kill behavior is platform-specific, not version-specific.

2. The kill instant coincides with an engine maintenance pass, not just caffeinate. A filesystem sweep bounded to the kill window found ~/.claude/backups/.claude.json.backup.1786327386809 created at 04:03:06 — the epoch-ms filename decodes to the kill second — and deleted again within ~2 minutes (the directory is empty now). So at each grid tick the engine appears to run a maintenance pass: config backup + caffeinate -i -t 300 + (sometimes) SIGTERM of tracked background tasks.

Anchor still unexplained: no persisted timestamp on the :03/:33 phase exists in .claude.json or under ~/.claude, ~/Library/Application Support/Claude, or ~/Library/Caches — yet the phase survived an engine restart (03:04:58 start, first observed tick 04:03:06, matching the previous engine's grid to ±2s). The 03:33 boundary left no file trace at all, consistent with not every tick performing the backup (or ticking).

Hypothesis for whoever owns this code path: a periodic maintenance/backup task on macOS whose scheduling anchor lives outside the engine process, and which tears down tracked background Bash tasks as a side effect. The Windows build either lacks the anchor or lacks the teardown.

jroxenhed · 21 days ago

Correction to my previous comment, plus the cross-platform mechanism separation.

Correction first: the backup file was not "deleted within ~2 minutes" — that was my own ls without -a hiding dotfiles (.claude.json.backup.*). The directory actually holds a rolling window of five backups on both platforms. My apologies for the noise; the corrected picture below is stronger, not weaker.

macOS backup ledger tonight (filenames are epoch-ms; they decode to creation time):

04:03:06   <- grid tick, waiter SIGTERMed at 04:03:06
04:06:23   <- activity (message handling)
04:22:59   <- activity
04:33:06   <- grid tick, second waiter SIGTERMed at 04:33:05, caffeinate -i -t 300 caught still running
04:36:41   <- activity

Windows control (same fleet, continuous 1-second watch of the backups dir from 04:10:21 to 04:35:31, spanning the 04:33 boundary): the same config-backup mechanism exists and fires — but only on activity (six decoded timestamps all track actual tool use), with no occurrence on any :03/:33 phase and nothing at 04:33. A tracked background task armed 04:10:16 crossed the 04:33 boundary alive (25m+ old, exit 0 later on its expected event).

Reading: both platforms run the same maintenance/backup pass on activity. macOS additionally fires it unprompted on the :03/:33 grid, and the SIGTERM of tracked background tasks rides only those grid-scheduled firings (2/2 tonight on 2.1.226: 04:03:06 and 04:33:05). The platform-specific part is the scheduler, not the pass. Whatever puts the pass on the wall-clock grid on macOS — with a phase that survives engine restarts — is the component to look at, and it appears to tear down tracked Bash tasks as a side effect when it fires.

One verification note for anyone checking the filename decode: on Windows the backup preserves the source .claude.json mtime, so ls -l shows a time that disagrees with the filename by one backup interval — the filename, not the mtime, is the creation time. (Credit for the Windows watch, the mtime trap, and catching the dotfile-ls error: the Windows-side session in our fleet.)

dbaggott · 21 days ago

Independent reproduction, CLI 2.1.226, macOS (Darwin 25.4.0, Apple Silicon). Adding the one thing this thread infers but hasn't captured: the sender, by pid.

A bash trap can't see who signalled it, but sigaction with SA_SIGINFO gives the handler siginfo_t.si_pid — and needs no SIP change, so dtrace proc:::signal-send isn't the only route. I ran a small C sentinel as a tracked background task, shaped like a victim:

SIGNAL=15 sender_pid=30824 sender_uid=501
30824 30676 30824  claude --dangerously-skip-permissions   (7h41m old)

The sender is that session's own engine process, which kept running afterwards. Signals arrive as a pair — the directed TERM, then a second with si_pid=0 milliseconds later, which will clobber the first sender unless your handler keeps first-writer-wins.

Two sessions were captured in the same event, each naming itself. From a monitor running detached outside every session:

02:44:24.072Z  session A's wrapper shells die
02:44:24.562Z  session A's two tasks, 0.49s later       -> sentinel says sender_pid = A's engine
02:44:29.584Z  session B's watcher + sentinel, 5s later -> sentinel says sender_pid = B's engine

Neither reached into the other; both sessions survived. So the "several sessions at once" pattern is N engines independently reaping their own tasks, not one sweeper — which is what a wall-clock-aligned trigger would look like.

Independent confirmation of your 1800s period: two kills in one session at 18:39:58.907Z and 19:09:58.876Z — 1799.969s apart, different hardware and version.

Also confirmed: it hits nested claude -p subagent sessions (child engine killing its own task), and status=killed / "was stopped" appears to be a reliable marker — every external kill shape I tried reports failed instead (144 / 143 / 1 depending on shape), so the two are distinguishable in triage.

Couldn't corroborate the caffeinate -i -t 300-at-kill-instant observation — pmset shows no assertion in either kill window, but it logs assertion death rather than creation, so that's inconclusive rather than contradictory.

Happy to share the sentinel source (~90 lines of C) or the fuller elimination list (compaction, idle, exit, lock, sleep, network, memory pressure all ruled out on the captured event) if useful.

Showing cached comments. Read the full discussion on GitHub ↗