Durable scheduled tasks (CronCreate `durable: true`) never fire after an in-app `/resume`
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:
- In folder X, start
claude, send any prompt, then/exit. Note the session id. - In folder X, start
claudeagain (a fresh process), then run/resume <that id>. - Ask it to call
CronCreatewithcron: "* * * * *",recurring: true,durable: true, and any short prompt. - 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-slimwithnpm 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
CronListafter--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.lockownership 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 <your auth env vars> 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>