Durable scheduled tasks (CronCreate `durable: true`) never fire after an in-app `/resume`

Status Open
Reported on v2.1.283
Maintainer reply None cached
Activity 0 comments · opened Sep 29, 2026

What's wrong?

In a Claude Code process that switched to another conversation with the in-app /resume <id> command, recurring tasks created with CronCreate and durable: true never fire. The task is written to .claude/scheduled_tasks.json, CronList shows it, and the REPL sits idle, but no prompt is ever delivered. There is no error.

The same request works in every nearby case:

  • a fresh session, durable → fires
  • a session started with claude --resume <id> from the command line, durable → fires
  • after an in-app /resume, session-only (durable: false) → fires
  • restarting Claude Code in the same folder → the stuck durable task is picked up and fires in the new process

We hit this with an hourly task in a session that had been resumed in-app. It never fired over 9+ hours.

Steps to reproduce

The repro.sh below automates this on a private tmux server. It uses every-minute tasks and waits about 4 minutes. By hand:

  1. In folder X, start claude, send any prompt, then /exit. Note the session id.
  2. In folder X, start claude again (a fresh process), then run /resume <that id>.
  3. Ask it to call CronCreate with cron: "* * * * *", recurring: true, durable: true, and any short prompt.
  4. Wait a few minutes. The task never fires; no transcript line carries a scheduledTaskId.

For comparison, durable: false in step 3 fires every minute, and so does step 3 in a process started with claude --resume <id>.

Output of repro.sh

case setup                                        durable  fires
A    fresh session                                true     3
B    fresh process + in-app /resume <old id>      true     0
C    claude --resume <old id>                     true     3
D    fresh process + in-app /resume <old id>      false    3

In case B, scheduled_tasks.lock and the task record both name the resumed session and the same pid, so ownership looks correct on disk:

lock: {"sessionId":"<resumed-session-id>","pid":<pid>,"procStart":"<ticks>","acquiredAt":<ms>}
task: {"id":"<task-id>","cron":"* * * * *","createdBySessionId":"<resumed-session-id>","createdByPid":<pid>}

Expected

Durable tasks created after an in-app /resume fire the same way as in a fresh or --resumed session.

Environment

  • Claude Code 2.1.283 and 2.1.284, CLI
  • Reproduced in a clean container: node:22-bookworm-slim with npm install -g @anthropic-ai/claude-code@latest (2.1.284), tmux 3.3, default settings. The Dockerfile is below.
  • Also seen on a Linux workstation, inside tmux 3.6, in auto permission mode

Related

  • #57287: tasks lost from CronList after --continue (a different symptom; here the task stays listed)
  • #75188: durable + recurring never firing (Windows, 2.1.202; here durable + recurring works outside in-app /resume)
  • #91943: scheduled_tasks.lock ownership checks

<details><summary><code>repro.sh</code></summary>

#!/usr/bin/env bash
# Repro: durable CronCreate jobs never fire in a session switched to with the
# in-app /resume command. Session-only jobs in that session fire, and so do
# durable jobs in a fresh session or one started with `claude --resume <id>`.
#
# Runs four interactive Claude Code sessions on a private tmux server (never
# touches your own tmux), each asked to create an every-minute job,
# waits, and counts how many times each job fired (transcript lines carrying
# a scheduledTaskId).
#
#   A  fresh session                                   expected: fires
#   B  fresh process, then in-app `/resume <old id>`    BUG: never fires
#   C  `claude --resume <old id>` from the command line expected: fires
#   D  like B but durable: false                        fires (control)
#
# Usage: ./repro.sh            (CLAUDE_BIN=claude, WAIT=240 seconds)
# Needs: tmux, a logged-in claude. Makes a few tiny model calls.
set -euo pipefail

CLAUDE_BIN=${CLAUDE_BIN:-claude}
WAIT=${WAIT:-240}
ROOT=$(mktemp -d "$HOME/cron-resume-repro.XXXXXX")
SOCK="$ROOT/tmux.sock"
T() { tmux -S "$SOCK" "$@"; }
cleanup() { [ -S "$SOCK" ] && tmux -S "$SOCK" kill-server 2>/dev/null; true; }
trap cleanup EXIT

projects_dir() { echo "$HOME/.claude/projects/$(printf '%s' "$1" | sed 's/[^A-Za-z0-9]/-/g')"; }

send() {  # send <window> <text>: paste as one prompt, then Enter
  printf '%s' "$2" | T load-buffer -b repro -
  T paste-buffer -p -r -d -b repro -t "repro:$1"
  sleep 0.7
  T send-keys -t "repro:$1" Enter
}

wait_ready() {  # wait for the input prompt; accept the folder-trust dialog if shown
  for _ in $(seq 60); do
    local screen; screen=$(T capture-pane -p -t "repro:$1")
    if grep -q "trust this folder" <<<"$screen"; then
      T send-keys -t "repro:$1" Down; sleep 0.3; T send-keys -t "repro:$1" Enter; sleep 2; continue
    fi
    grep -q "^❯" <<<"$screen" && return 0
    sleep 1
  done
  echo "window $1 never showed the prompt" >&2; T capture-pane -p -t "repro:$1" | tail -20 >&2; exit 1
}

old_session() {  # old_session <dir>: an interactive session that has ended; prints its id
  # (not `claude -p`: in-app /resume doesn't offer print-mode sessions)
  local w="old-$(basename "$1")"
  T new-window -d -t repro: -n "$w" -c "$1" "$CLAUDE_BIN"
  wait_ready "$w" >&2
  send "$w" "Reply with just: ok"
  for _ in $(seq 60); do T capture-pane -p -t "repro:$w" | grep -q "^● ok" && break; sleep 1; done
  send "$w" "/exit"
  for _ in $(seq 30); do T list-windows -t repro -F '#W' | grep -qx "$w" || break; sleep 1; done
  basename "$(ls -t "$(projects_dir "$(cd "$1" && pwd -P)")"/*.jsonl | head -1)" .jsonl
}

ask_cron() {  # ask_cron <window> <durable true|false> <word>
  send "$1" "Load the CronCreate tool with ToolSearch if needed, then call CronCreate with cron '* * * * *', recurring true, durable $2, prompt 'Reply with only the word $3 and nothing else.' End your turn with one short line."
}

fires() {  # fires <dir> <word>: fired prompts for that word across the dir's transcripts
  local d; d=$(projects_dir "$(cd "$1" && pwd -P)")
  cat "$d"/*.jsonl 2>/dev/null | grep '"scheduledTaskId"' | grep -c "$2" || true
}

mkdir -p "$ROOT"/{a,b,c,d}
echo "claude: $("$CLAUDE_BIN" --version 2>/dev/null | head -1)   tmux: $(tmux -V)   workdir: $ROOT"

# Everything starts one at a time: some launch wrappers rewrite
# ~/.claude/settings.json on startup, and concurrent rewrites can corrupt it.
T new-session -d -s repro -x 160 -y 45 -n keep -c "$ROOT" "sleep 100000"  # keeps the server up
B_OLD=$(old_session "$ROOT/b"); C_OLD=$(old_session "$ROOT/c"); D_OLD=$(old_session "$ROOT/d")
echo "old sessions: b=$B_OLD c=$C_OLD d=$D_OLD"

T new-window -d -t repro: -n a -c "$ROOT/a" "$CLAUDE_BIN"; wait_ready a
T new-window -d -t repro: -n b -c "$ROOT/b" "$CLAUDE_BIN"; wait_ready b
T new-window -d -t repro: -n c -c "$ROOT/c" "$CLAUDE_BIN --resume $C_OLD"; wait_ready c
T new-window -d -t repro: -n d -c "$ROOT/d" "$CLAUDE_BIN"; wait_ready d

send b "/resume $B_OLD"; send d "/resume $D_OLD"; sleep 5
wait_ready b; wait_ready d

ask_cron a true  TICKA
ask_cron b true  TICKB
ask_cron c true  TICKC
ask_cron d false TICKD
echo "jobs requested at $(date -u +%H:%M:%S)Z; waiting ${WAIT}s…"
sleep "$WAIT"

printf '\n%-4s %-44s %-8s %s\n' case setup durable fires
printf '%-4s %-44s %-8s %s\n' A "fresh session" true "$(fires "$ROOT/a" TICKA)"
printf '%-4s %-44s %-8s %s\n' B "fresh process + in-app /resume <old id>" true "$(fires "$ROOT/b" TICKB)"
printf '%-4s %-44s %-8s %s\n' C "claude --resume <old id>" true "$(fires "$ROOT/c" TICKC)"
printf '%-4s %-44s %-8s %s\n' D "fresh process + in-app /resume <old id>" false "$(fires "$ROOT/d" TICKD)"

echo; echo "B lock file vs task record:"
cat "$ROOT/b/.claude/scheduled_tasks.lock" 2>/dev/null; echo
python3 - "$ROOT/b/.claude/scheduled_tasks.json" <<'EOF' 2>/dev/null || true
import json, sys
for t in json.load(open(sys.argv[1]))["tasks"]:
    print({k: t[k] for k in ("id", "cron", "createdBySessionId", "createdByPid") if k in t})
EOF
python3 - "$ROOT/b/.claude/scheduled_tasks.json" "$B_OLD" <<'EOF' 2>/dev/null || true
import json, sys
t = json.load(open(sys.argv[1]))["tasks"][-1]
print("B's job belongs to the resumed session (valid repro)" if t.get("createdBySessionId") == sys.argv[2]
      else "WARNING: B's /resume did not take effect, so this run proves nothing")
EOF
echo "(the lock and the job both name the resumed session, yet B never fires)"
echo; echo "Transcripts are under ~/.claude/projects/*cron-resume-repro*; delete $ROOT and those when done."

</details>

<details><summary><code>Dockerfile</code> (clean-container run: <code>docker build -t repro .</code> in a folder with <code>repro.sh</code>, then <code>docker run --rm &lt;your auth env vars&gt; repro ./repro.sh</code>)</summary>

# Public sources only: Docker Hub node image + @anthropic-ai/claude-code from npmjs.
FROM node:22-bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends tmux python3 procps ca-certificates git \
 && rm -rf /var/lib/apt/lists/*
RUN npm install -g @anthropic-ai/claude-code@latest && claude --version
RUN useradd -m -s /bin/bash tester
USER tester
WORKDIR /home/tester
# Skip first-run onboarding screens (theme picker etc.).
RUN mkdir -p .claude && echo '{"hasCompletedOnboarding": true, "theme": "dark"}' > .claude.json
# Build from a folder that holds repro.sh.
COPY --chown=tester repro.sh /home/tester/repro.sh
ENV SHELL=/bin/bash

</details>

View original on GitHub ↗