Background tasks SIGTERMed on an exact 30-minute internal timer (macOS CLI, long-lived session) — undocumented kill path, exit 144
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
claudePID running for 9 days (no restarts across the kills — verified viaps lstartancestry walk from inside the session) remoteControlAtStartup: true, custom statusLine (ccstatusline), OAuth (Keychain credentials)- The background task is a small
/bin/shscript 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 (
pgrepimmediately 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
- What runs on the CLI's 30-minute internal timer, and under what conditions does it terminate tracked background tasks?
- 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
Showing cached comments. Read the full discussion on GitHub ↗
5 Comments
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.
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):
claude --continue), replacing the 11-day 2.1.221 process from the original report.caffeinate -i -t 300spawned as a direct child of the engine pid at the kill instant (trap log +pssnapshot).Three inferences:
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.
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.1786327386809created 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.jsonor 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.
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
lswithout-ahiding 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):
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.jsonmtime, sols -lshows 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-lserror: the Windows-side session in our fleet.)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
trapcan't see who signalled it, butsigactionwithSA_SIGINFOgives the handlersiginfo_t.si_pid— and needs no SIP change, sodtrace proc:::signal-sendisn't the only route. I ran a small C sentinel as a tracked background task, shaped like a victim: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=0milliseconds 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:
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.907Zand19:09:58.876Z— 1799.969s apart, different hardware and version.Also confirmed: it hits nested
claude -psubagent sessions (child engine killing its own task), andstatus=killed/ "was stopped" appears to be a reliable marker — every external kill shape I tried reportsfailedinstead (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 —pmsetshows 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.