●FORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtask●LIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its own●MCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS●PLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callback●SONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15●IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as October●FORK — Claude Code 2.1.212 changes what /fork does: it copies your conversation into a new background session with its own row in claude agents, so you can keep working. The old in-session subagent is now /subtask●LIMITS — WebSearch calls are now capped at 200 per session by default, and subagent spawns get the same 200 ceiling, so a runaway search or delegation loop stops on its own●MCPBG — MCP tool calls running past two minutes now move to the background automatically, keeping the session usable. Tune the threshold with CLAUDE_CODE_MCP_AUTO_BACKGROUND_MS●PLANFIX — Fixed plan mode auto-running file-modifying Bash commands such as touch and rm without a permission prompt or an SDK canUseTool callback●SONNET5 — Claude Sonnet 5 is running on introductory pricing of $2 per million input tokens and $10 per million output. After August 31 it moves to $3 and $15●IPO — Bankers are reportedly lining up investor meetings for Anthropic ahead of a possible public listing as soon as October
Why Cowork Scheduled Tasks Stop Mid-Run and How to Recover
Diagnose Cowork scheduled task failures using measured exit codes and real error output — why safe.directory delays failure instead of fixing it, how to pick a writable root before cloning, and how to structure logs with reason codes.
You set up an automation, and one morning you notice it stopped somewhere in the middle. If you have run Cowork scheduled tasks for any length of time, you know the feeling — and the harder part is that you often cannot tell why it stopped.
I run article generation tasks across several sites as a solo developer, and over time a few clear patterns emerged. Here is how I organize them.
The second half of this article is not a tidy summary of theory. On the very day I wrote it, a scheduled run hit a working repository that had been left owned by another user and could not be written to. The exit codes and error strings below are the ones I captured in that session.
Why Scheduled Tasks "Disappear" Silently
The awkward part about scheduled tasks is that a failure does not always announce itself. A manual run shows you the problem on screen immediately. A scheduled run happens in the background, so a mid-run exit can go unnoticed for days.
The causes fall into three broad patterns.
Permission dialog lockups are the most common. Cowork scheduled tasks run when nobody is at the keyboard, so any confirmation dialog leaves the run waiting for an answer that never comes. AskUserQuestion, the Read / Write / Edit file tools, and request_cowork_directory all trigger dialogs. Use them in a scheduled context and the run stalls with nobody there to click "Allow."
Disk exhaustion (ENOSPC) shows up regularly in real operation. Anything that runs Claude Code or git clone on a schedule gradually eats /tmp. If npm package installation is in the loop, a single run can consume hundreds of megabytes.
Git lock conflicts happen when a previous run ended halfway. A leftover .git/index.lock makes the next run fail with fatal: Unable to create ... .git/index.lock.
These three are the manageable kind, because they surface as errors. The genuinely hard ones are the failures that produce no error at all and simply pretend to succeed. Those come later in the article.
Diagnosis: Identifying Which Pattern Hit You
Start with the run log. Cowork keeps a session log for scheduled tasks, so read the last message it emitted.
# Free space in /tmp (the ENOSPC signal)df /tmp --output=avail -m | tail -1# Leftover git lock filesfind /tmp/repos -name "*.lock" 2>/dev/null# Who owns the working root — and who are we?stat -c '%U:%G %a %n' /tmp/repos/* 2>/dev/nullid -u
If the last log line lands right after an AskUserQuestion call or a Read tool invocation, it is a permission dialog. If you see ENOSPC or No space left on device, it is disk. If you see index.lock or fatal: Unable to create, it is a lock conflict.
The stat and id -u pairing at the end is deliberate. The uid your task runs as is not guaranteed to be stable, and a directory left behind by a previous process under a different user will fail every write from that point onward. The next section shows how nastily that surfaces.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Measured proof that safe.directory leaves git status at exit 0 while fetch alone fails with exit 255 — reads pass, so the failure surfaces minutes later than it should
✦A preflight that settles on a writable root before cloning (uid 65534 detection, $HOME fallback, free-space threshold), sized from measured numbers: 41MB per clone, 2.8s, 2,990MB free
✦Reason-coded structured logs and aggregation one-liners that tell you in seconds which morning things actually broke
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
One nuance worth checking: the SKILL.md itself is often clean, while the scheduled task prompt that invokes it is the actual offender. Make sure the task body explicitly states that it runs the whole workflow autonomously and never calls AskUserQuestion.
Disk Exhaustion (ENOSPC)
# Staged cleanup# 1. Drop other repositoriesfor DIR in /tmp/repos/*; do [ -d "$DIR" ] && rm -rf "$DIR" && echo "removed: $DIR"done# 2. Clear the npm cachenpm cache clean --force 2>/dev/null# 3. Clear .next build cachesfind /tmp -name ".next" -type d 2>/dev/null -exec rm -rf {} + 2>/dev/nulldf /tmp --output=avail -m | tail -1
The structural fix is to remove npm install from the scheduled workflow entirely. With Next.js on Cloudflare Workers, committing and pushing the MDX is enough — CI handles the build. Narrow the flow down to "clone → write MDX → push" and a single run writes 41MB, measured (11MB of which is .git, using a --depth 1 shallow clone, completing in 2.8 seconds). Compared to a workflow that installs packages, that is an order of magnitude difference.
safe.directory Does Not Remove the Failure — It Delays It
This is the part I most want to pass along.
Reuse a working clone long enough and one morning you meet this:
fatal: detected dubious ownership in repository at '/tmp/repos/claudelab.net'
To add an exception for this directory, call:
git config --global --add safe.directory /tmp/repos/claudelab.net
Git even hands you the fix, so adding safe.directory feels like the obvious move. I did exactly that, and then spent a long time confused.
Here is the environment, measured:
$ git --versiongit version 2.34.1$ stat -c '%U:%G %a' /tmp/repos/claudelab.netnobody:nogroup 755$ echo "dir_uid=$(stat -c '%u' /tmp/repos/claudelab.net) my_uid=$(id -u)"dir_uid=65534 my_uid=1222
The directory belongs to uid 65534 (nobody) with mode 755. We are uid 1222. There was never any write permission here.
So what does safe.directory actually buy you? Measured results:
Command
Result after adding safe.directory
git status --porcelain
exit 0 (passes)
git log --oneline -1
exit 0 (passes)
git rev-parse HEAD
exit 0 (passes)
git fetch origin main
exit 255
git pull --rebase origin main
error: cannot open .git/FETCH_HEAD: Permission denied
Writing an MDX file
Permission denied
Every read command passes with exit 0.
safe.directory suppresses the ownership check only. It does nothing to file permissions. Read-only operations are satisfied by the r-x bits in mode 755, so they succeed — and everything dies the moment a write is required.
The problem is not that the failure disappears. It is that discovery gets postponed.
A Step 0 that verifies repository health with git status sails right through
The actual failure lands at the end, when you try to commit the article you just wrote
Every minute of work spent in between is thrown away
There is a worse branch. If your workflow does not check the exit code of git pull, the pull fails while the run keeps going against a tree that is days old. The error is right there in the output, but the shape of the log looks normal, so a morning skim will not catch it.
The operational rule I took from this is simple: do not reach for safe.directory in a scheduled task.
safe.directory is the right tool when the files really are yours but git cannot tell — mounted volumes, or ownership skew introduced by sudo.
What we have here is different: the files genuinely are not ours. The answer is not an exception, it is moving somewhere you can write. When you find a directory owned by another uid, do not repair it. Discard it and re-fetch somewhere writable. That was consistently the shortest path back.
The Other Trap: Stale Clones and Failures That Look Like Success
Three more failures that never raise an error. I have hit all of them in production.
The first is a reused clone going quietly stale. A persistent repository under /tmp/repos drifts within days. git pull appears to succeed, but your local article list is from last week and you end up reworking something you already improved. Running several sites in parallel makes this kind of mix-up easy. Candidate selection and state checks should run against content fetched in this run, not against a clone you have been carrying around.
# Avoid the stale clone; re-fetch somewhere writableWORK="$HOME/repos/your-repo" # if /tmp is owned by another uid, go under $HOMEif [ -d "$WORK/.git" ] && [ -w "$WORK/.git" ]; then cd "$WORK" && git fetch origin main && git reset --hard origin/mainelse rm -rf "$WORK" git clone --depth 1 "https://${GITHUB_TOKEN}@github.com/username/repo.git" "$WORK"fi
The second is a commit failing in silence while push reports success. Right after a clone, git identity may be unset; run git commit in that state and no commit is created. The following git push simply has nothing to send, so it exits successfully and the log looks clean. Pass identity explicitly, and judge the outcome by comparing local and remote hashes.
git -c user.email="you@example.com" -c user.name="Your Name" commit -m "..."git push origin main# Judge by hash equality, not by what got printedLOCAL=$(git rev-parse HEAD)REMOTE=$(git rev-parse origin/main)[ "$LOCAL" = "$REMOTE" ] && echo "✅ landed" || echo "⛔ not landed — resend required"
The third is your own shell hiding the failure. Pipe stderr into head or tail to make it readable, and $? now reports the exit code of the last element of the pipeline. The command can fail while $? cheerfully reads 0.
I measured this against the same permission error:
$ touch /tmp/repos/claudelab.net/.probe 2>&1 | head -1 > /dev/null$ echo "$? ${PIPESTATUS[0]}"0 1 # ← $? is 0. The real failure is PIPESTATUS[0]=1$ touch /tmp/repos/claudelab.net/.probe 2>/dev/null$ echo "$?"1 # ← drop the pipe and you get the truth
set -e will not save you either. The pipeline's exit status is considered 0, so set -e never fires and the rest of the script runs on.
Two fixes: keep commands you branch on out of pipelines, or declare set -o pipefail (or read ${PIPESTATUS[0]}) when you must pipe.
set -o pipefailtouch "$WORK/.probe" 2>&1 | head -1 > /dev/nullecho "$?" # → 1 (propagates correctly with pipefail)
Log dates hide the same class of failure. Build a log filename with a bare date and the host may be on UTC, so a run that is still "today" in Japan picks yesterday's filename and overwrites yesterday's log. Generate dates with an explicit timezone.
D="$(TZ=Asia/Tokyo date +%Y-%m-%d)" # a bare date can point at yesterday in UTC
Preflight: Settle on a Writable Root Before You Clone
Put the pieces together and an ordering problem appears.
I used to write these as "clone first, fix problems as they come." With that order, permission and space problems surface after the clone — or much later.
Flip it. Settle on a place you can write, then clone into it. The preflight does not return a boolean; it returns the path of a working root you can actually write to.
#!/usr/bin/env bashset -uo pipefailMIN_FREE_MB=500# Find a writable working root and print its path# Try candidates in order; take the first that qualifiespick_work_root() { local root avail for root in /tmp/repos "$HOME/repos"; do mkdir -p "$root" 2>/dev/null || continue # 1. Can we actually write? (-w covers owner, mode, and mount state at once) [ -w "$root" ] || { echo "skip $root: not writable" >&2; continue; } # 2. Is there enough space? avail=$(df -m "$root" --output=avail 2>/dev/null | tail -1 | tr -d ' ') if [ "${avail:-0}" -lt "$MIN_FREE_MB" ]; then echo "skip $root: ${avail}MB < ${MIN_FREE_MB}MB" >&2 continue fi echo "$root" # ← emit the chosen path return 0 done return 1}WORK_ROOT="$(pick_work_root)" || { echo "E_PERM_ROOT: no writable working root available" exit 20}echo "work_root=$WORK_ROOT (avail=$(df -m "$WORK_ROOT" --output=avail | tail -1 | tr -d ' ')MB)"
Decide clone reuse the same way — branch on "can I write to it," never on "does it exist."
NAME="your-repo"WORK="$WORK_ROOT/$NAME"REPO_URL="https://${GITHUB_TOKEN}@github.com/username/${NAME}.git"if [ -d "$WORK/.git" ] && [ -w "$WORK/.git" ]; then # Reusable: sync to latest (fetch + reset, not pull) cd "$WORK" if ! git fetch origin main 2>/dev/null; then echo "fetch failed — recreating" >&2 rm -rf "$WORK" 2>/dev/null git clone --depth 1 -q "$REPO_URL" "$WORK" || exit 21 else git reset --hard origin/main --quiet fielse # Not reusable: remove if we can, then re-fetch rm -rf "$WORK" 2>/dev/null git clone --depth 1 -q "$REPO_URL" "$WORK" || exit 21ficd "$WORK" || exit 21echo "head=$(git rev-parse --short HEAD)"
Three decisions worth explaining.
1. Let -w make the call. Comparing owner uids yourself is tempting and it is the wrong instinct. A stat uid comparison assumes "I own it, therefore I can write it," which gets the answer wrong for group-writable directories and for read-only mounts. The speed difference is not small either: across 1,000 checks, test -w took 11ms while a stat-based uid comparison took 3,748ms, since stat forks a process every time. -w wins on correctness and on cost.
2. -w alone is not always enough. A writable .git with an unwritable worktree is a real combination. Probe the path you are actually going to write to.
3. Prefer fetch + reset --hard over pull.pull carries merge and rebase failure paths, and a conflict can leave you waiting on input that never arrives. For a disposable clone with no local changes worth keeping, reset --hard origin/main gives you a predictable result.
The 500MB threshold has reasoning behind it. One shallow clone measured 41MB with 11MB of .git, so roughly 50MB per site including the article writes. My /tmp had 2,990MB free of 9,741MB, so even four sites at once come to about 200MB. 500MB gives roughly 2x headroom. Add npm install back into the workflow and this estimate collapses.
Structure Your Logs with Reason Codes
Each failure above has its own recovery path. A log that only says "failed" means tomorrow-morning-you repeats the entire investigation.
Collapse failures into reason codes and that decision takes seconds instead.
Code
Meaning
Signature in the log
Auto-recoverable
E_PERM_ROOT
Working root owned by another uid
dubious ownership / Permission denied
Yes — switch to a writable root
E_DISK
Out of space
No space left on device
Yes — staged cleanup
E_LOCK
Leftover index.lock
fatal: Unable to create ... index.lock
Yes — remove lock, reset
E_IDENTITY
Git identity unset
No commit created, passes silently
Yes — pass -c explicitly
E_STALE
Continued despite a failed fetch
No error at all (most dangerous)
Yes — check exit codes
E_APPROVAL
Waiting on a permission dialog
Silent exit right after a file tool
No — SKILL.md must change
Splitting the table by auto-recoverability is the point. E_APPROVAL will recur forever until a human edits the SKILL.md. Mix it in with the others and you manufacture a false sense of safety: "it fails daily, but it always recovers."
Write logs as one key=value record per line so they stay machine-readable.
log_line() { local result="$1" reason="${2:-}" detail="${3:-}" printf '%s site=%s result=%s reason=%s detail="%s"\n' \ "$(TZ=Asia/Tokyo date +%Y-%m-%dT%H:%M:%S%z)" \ "${SITE:-unknown}" "$result" "${reason:-NONE}" "$detail" \ >> "${LOG_DIR}/$(TZ=Asia/Tokyo date +%Y-%m-%d).txt"}# Usagelog_line SUCCESSlog_line FAILED E_PERM_ROOT "uid 65534 != $(id -u) at /tmp/repos"log_line SKIPPED E_DISK "avail=180MB"
Reviewing 30 days is then a two-liner.
# Occurrences per reason code, most frequent firstgrep -h -o 'reason=[A-Z_]*' "${LOG_DIR}"/2026-0[67]-*.txt \ | sort | uniq -c | sort -rn# How long has it been failing? (days with failures, most recent last)grep -l 'result=FAILED' "${LOG_DIR}"/*.txt | sort | tail -14
Once I started aggregating, my priorities rearranged themselves. The high-count reason codes were mostly the ones already recovering on their own. The one that actually deserved my hands was E_APPROVAL — low count, zero auto-recovery. Working top-down by frequency would have had me fixing things in the wrong order.
Rolled into Step 0 of a SKILL.md, it looks like this. The ordering carries the weight: pick the writable root first, clone second.
set -uo pipefail# 1. Clear leftovers (only where we can write)[ -w "${WORK:-/nonexistent}/.git" ] && rm -f "$WORK/.git/index.lock" 2>/dev/null# 2. Settle on a writable root (abort with E_PERM_ROOT if none)WORK_ROOT="$(pick_work_root)" || { log_line FAILED E_PERM_ROOT "no writable root"; exit 20; }# 3. Space check (free space progressively from other repositories)FREE_MB=$(df -m "$WORK_ROOT" --output=avail | tail -1 | tr -d ' ')if [ "${FREE_MB:-0}" -lt 500 ]; then for OTHER in "$WORK_ROOT"/*; do [ "$OTHER" != "$WORK" ] && rm -rf "$OTHER" 2>/dev/null done FREE_MB=$(df -m "$WORK_ROOT" --output=avail | tail -1 | tr -d ' ') [ "${FREE_MB:-0}" -lt 500 ] && { log_line FAILED E_DISK "avail=${FREE_MB}MB"; exit 22; }fi# 4. Sync if writable, re-fetch if not# 5. Always inspect exit codes (never behind a pipe)
Building in "recovers by itself on the next run" from the start is the rule that holds up over months. And the one class of failure that cannot recover by itself — the permission dialog — has to be closed in how you write the SKILL.md, not in code.
Eight things I check before putting a task on a schedule.
Are Read / Write / Edit / AskUserQuestion gone from both the SKILL.md and the task prompt?
Does working-root selection happen before the clone?
Is the writable check done with -w, against the path you will actually write?
Are you avoiding safe.directory? (Owned by another uid means relocate, not repair.)
Are you using fetch + reset --hard instead of pull, and checking the exit code?
Are branch-critical commands kept out of pipelines (or is set -o pipefail declared)?
Do you pass -c user.email / -c user.name before committing, and judge success by hash equality?
Are log dates generated with TZ=Asia/Tokyo date?
Items 2, 4, and 6 are the ones this round of measurement made me reverse. None of them produce a symptom while things are working. The symptoms arrive during the hours nobody is watching.
Open your own SKILL.md and read just the first ten lines of Step 0. Is there a line that confirms you can write, before the clone happens? If not, that is where your next outage begins.
Share
Thank You for Reading
Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.