Every one of my publishing lanes went dark for four days, and every script involved exited with status 0. Nothing had crashed. The files themselves had quietly stopped existing on disk — macOS had uploaded them to iCloud and deleted the local copies to "optimize storage."
Why This Matters
What it means for automation to depend on its environment
When you run 160+ launchd jobs around the clock, the execution environment itself becomes a failure source before your script logic does. Ports get exhausted, processes orphan and pile up, memory never frees — I wrote about that class of resource leak last time. This is a completely different kind of total failure that happened the very next day.
The files had become fatal to read.
Not a bug in my code. Not a filesystem bug. An unintended side effect of a mechanism macOS runs under the name "optimization."
What optimize-storage actually does
macOS's "Optimize Storage" (System Settings → General → Storage → Optimize Storage), on a machine with iCloud Drive enabled, uploads files under Desktop and Documents to iCloud and deletes the local copies when free disk space gets tight. In Finder they still look like normal icons, but there is no local data — they are in a "dataless" state. Click one and it downloads automatically. For a human user, that's an acceptable tradeoff.
The problem is automation scripts.
python3's open(), pathlib.Path.read_text(), cat, jq, cp — all of them die instantly on a dataless file with Errno 11: EDEADLK: Resource deadlock avoided.
The name "Resource deadlock" makes you suspect a deadlock, but this is a POSIX errno code that macOS repurposes to mean "waiting for a file download." No lock is contended. No thread is stuck. The mere fact that "the data isn't local" surfaces to the process as a fatal error code.
You can also get EAGAIN (resource temporarily unavailable). That one shows up as a race right after a download starts.
The actual damage: four days of zero posts
On August 6, 2026, note's automated publishing stopped across every lane. The error log was a wall of EDEADLK.
Digging in, 69 article files under ~/Desktop/Article/ had gone dataless. Disk usage had hit 98% (25GB free), and iCloud had silently evicted the data at that moment.
That's exactly where the note lane was reading from. It couldn't read, so it couldn't post. It couldn't post, so there was no revenue. Starting August 3, four days of zero posts.
The lane producing most of my revenue had stopped in a form that showed neither errors nor "failure" alerts at a glance — that's what stung most. The script returns exit 0. It's treated as "no articles found, so nothing to do," so the liveness check passes as "success." Silent failures are the ones you discover last.
The previous day's (August 5) incident was "resource leak: memory and orphaned processes." A different kind of total failure arrived one day later, and both present as "all lanes stopped." The root causes differ; the surface is identical. Without a triage pattern, you burn an hour every time.
The Vault had the same problem
Something else I noticed the same day while working: the Markdown files under my Obsidian Vault (~/Documents/claude-obsidian/) were repeatedly going dataless too. CLAUDE.md and my learning notes get re-evicted as long as disk pressure continues, no matter how many times I materialize them with brctl download.
This isn't a Desktop-only problem. Every folder synced to iCloud Drive is in scope. Documents included. As long as your automation's read/write targets live there, the same thing happens every time the disk fills up.
The Overall Picture
The permanent fix has two pillars.
【問題の構造】
ディスク 98% → optimize-storage 発動
↓
Desktop/Documents 配下のファイル → iCloud退避(dataless)
↓
自動化スクリプトが open() → EDEADLK → 即死 → exit 0(静かな失敗)
↓
note レーン 4日間ゼロ投稿(8/3〜8/6)
【恒久策 A: ファイルを逃がす】
~/Desktop/Article/ → ~/content/article/ (実体を移動)
↓
~/Desktop/Article → ~/content/article のsymlink残置
(移行中もジョブを壊さない)
↓
パス定数を JS/MJS 6本 + Shell 3本 = 9ファイル更新(commit a1b37dc)
【恒久策 B: 読む側で EDEADLK を吸収する】
Documents/Desktop 配下を読む箇所
↓
read_text_resilient(path, attempts=4) を挟む
↓
EDEADLK / EAGAIN のときだけ
→ brctl download <path>(実体化をカーネルに要求)
→ 2秒 → 4秒 → 8秒 の指数バックオフでリトライ
それ以外の OSError → 即 raise(握りつぶさない)
4回目も失敗 → OSError をそのまま raise
Fix A: Move automation's read/write targets out of Desktop/Documents
This cuts the problem off at the root.
I moved the actual data of 796 article files from ~/Desktop/Article/ to ~/content/article/ and left a symlink at the original path. Even if a launchd job fires mid-migration, it can still read through the symlink, so the job doesn't break.
Updating the path constants is a one-line replacement per script. This time I just replaced Desktop/Article with content/article across 9 files total (JavaScript, MJS, Shell) and confirmed zero hits with a residual check (grep -r 'Desktop/Article') — commit a1b37dc.
This is the main line of defense. If you exclude ~/content/ from iCloud Drive sync, or simply put it on a non-iCloud-managed path directly under ~/, no dataless eviction happens even under disk pressure. Don't put automation's read/write targets in Desktop or Documents — that's the structural fix.
That said, for places where automation reads from a folder that needs iCloud sync — like the Vault (~/Documents/claude-obsidian/) — moving files doesn't solve it. That's where Fix B comes in.
Fix B: Absorb EDEADLK with read_text_resilient
Wrap code that reads from Documents or Desktop in a function with retry tolerance for EDEADLK.
import errno
import subprocess
import time
from pathlib import Path
def read_text_resilient(path: Path, attempts: int = 4) -> str:
"""
iCloud の dataless ファイルに対して brctl download → 指数バックオフでリトライ。
EDEADLK / EAGAIN 以外の OSError はリトライせず即 raise する。
"""
delays = [2, 4, 8] # リトライ間隔(秒): 初回は sleep なし
last_err: OSError | None = None
for i in range(attempts):
try:
return path.read_text(encoding="utf-8")
except OSError as e:
if e.errno not in (errno.EDEADLK, errno.EAGAIN):
raise # 権限エラー・存在しないパスなどは即死させる
last_err = e
if i < len(delays):
subprocess.run(
["brctl", "download", str(path)],
capture_output=True,
)
time.sleep(delays[i])
assert last_err is not None
raise last_err
The design's key point is the narrowing: retry only on EDEADLK and EAGAIN.
brctl download <path> is a macOS command that asks the kernel to re-download a file evicted to iCloud back to local storage. Sync takes time, so the waits grow 2s → 4s → 8s. If the fourth attempt (attempts=4) also fails, it throws that OSError as-is. I deliberately do not build a fallback that swallows the exception and returns an empty string. Returning empty string gets interpreted as "the file was empty," processing continues, and you reproduce exactly the silent failure of "the data vanished but it counted as success."
I added this function to obsidian-notion-sync/sync.py and applied it at the 4 places that read .md files under the Vault. All 51 existing tests pass.
Why a "silent fallback" is poison
Let's pause here.
An implementation that swallows EDEADLK and returns "" is easy to write. try/except into an empty string and "no error occurs." But what happens if you do that?
The script that reads articles and assembles posts decides "I read an empty file," concludes "no articles to post today," and returns exit 0. No alert fires. The liveness check passes as "healthy." You don't notice until you open the note dashboard — exactly as I failed to notice for four days.
The smarter the fallback, the later you find the problem. In automation, a "silent failure" is far worse than a loud one. A design that hates noise so much it erases the signal too makes the post-incident cost of failures skyrocket.
EDEADLK is an unambiguous state — "the data isn't local" — so retrying is meaningful. But if you retry four times and all four fail, then either "the environment is broken" or "the file itself is the problem," and the correct move is to propagate it upward as an exception. What the caller does about it is the caller's decision — that's how you place responsibility in error handling.
A triage pattern: separating three flavors of "everything is down"
In the actual incident response, multiple causes overlapped on the same day.
- Resource leak (memory, orphaned processes): the main cause on 8/5, the day before
- EDEADLK from iCloud dataless files: the main cause on 8/6 (the subject of this article)
-
Weekly quota exceeded (
You've hit your weekly limit · resets 10am): running in parallel the same day
All three look like the same surface symptom: "every lane is stopped." Dive into "everything is down" without a triage pattern and you'll burn time on a different cause while investigating memory.
The triage order is as follows.
① Look at the error code first. EDEADLK means a file problem. Timeout means a process/memory problem. You've hit your weekly limit means quota. Error messages don't lie.
② Look at what is not dying at the same time. In this case: if it's an iCloud problem, Python's open() dies but curl doesn't. If it's a memory problem, both Chrome and claude -p die. The breadth of the failure narrows down the nature of the cause.
③ Check free disk space. The root cause here was "disk at 98%." One command, df -h ~, shows it. If you're not under pressure, iCloud dataless eviction doesn't happen.
In the next section, I'll get concrete about the pitfalls I hit actually applying read_text_resilient, and what I screwed up during the migration off Desktop.
Implementation Details
Design decision ①: Why narrow by errno
The core of read_text_resilient is that instead of retrying every except OSError as e, it retries only when e.errno is EDEADLK or EAGAIN.
Why not retry everything?
OSError subclasses include a huge number of errors that will never succeed on retry: PermissionError (errno 13), FileNotFoundError (errno 2), IsADirectoryError (errno 21), and more. Trying to read a file you lack permission for four times just returns PermissionError four times. brctl download has no effect whatsoever on a permission error.
Only EDEADLK (errno 11) and EAGAIN (errno 11/35) indicate a state of "the data isn't local right now, but downloading it might make it readable." EDEADLK is when iCloud doesn't hold the data locally; EAGAIN is a race condition right after a download has started. Both share the property of being "temporary, and resolvable via brctl."
except OSError as e:
if e.errno not in (errno.EDEADLK, errno.EAGAIN):
raise # 権限エラー・存在しないパスは即死させる
That raise matters. Change it to pass or continue and you open a hole that silently swallows errors you never intended to swallow.
On macOS, errno.EAGAIN is 35 (unlike Linux's 11). To be safe, import errno and compare via the constants. Write magic numbers like e.errno not in (11, 35) and you will get it wrong when porting.
Design decision ②: brctl download is asynchronous, so where you put sleep matters
brctl download <path> is a command that asks the kernel to start a download. It does not wait for the download to complete. So if you call read_text() the instant the command returns, the data usually hasn't arrived yet.
subprocess.run(["brctl", "download", str(path)], capture_output=True)
time.sleep(delays[i]) # ← brctl の後に sleep する
Putting sleep before brctl is pointless. It's meaningful because you wait after invoking brctl. The waits grow 2s, then 4s, then 8s to accommodate variance in download time depending on file size and network conditions. Two seconds is plenty for a small Markdown file, but if an article file exceeds 10KB or iCloud's server is far away, even 8 seconds can be tight.
I made attempts=4 a function argument so it can be overridden in tests.
# テスト内での呼び出し例
content = read_text_resilient(mock_path, attempts=1) # 1回だけ試す
In production, attempts=4 (initial attempt + 3 retries) is reasonable, but actually waiting 14 seconds of sleep (2+4+8) in a test isn't practical, so I inject attempts=1.
Design decision ③: Why capture_output=True
subprocess.run(["brctl", "download", str(path)], capture_output=True)
Omit capture_output=True and brctl's output leaks into the calling process's stdout/stderr. In a script invoked from a launchd job, unintended output to stdout pollutes the logs. I also don't use the invocation's status code here (brctl's return code means "was it queued," not "did the download complete," so checking it is meaningless).
Applying it in sync.py: before/after at 4 sites
obsidian-notion-sync/sync.py has 4 places that read .md files under the Vault. The original code was a direct call to path.read_text(encoding="utf-8") in every case.
# before
weekly_brief = Path(vault_root / "wiki/hot.md").read_text(encoding="utf-8")
template = Path(vault_root / "templates/daily-brief.md").read_text(encoding="utf-8")
# after
weekly_brief = read_text_resilient(vault_root / "wiki/hot.md")
template = read_text_resilient(vault_root / "templates/daily-brief.md")
read_text_resilient hard-codes encoding="utf-8" internally, so callers can omit the argument. I replaced the remaining 2 sites with the same pattern, and all 51 existing tests (unit tests using a mocked Path.read_text) passed.
Guarding on the Shell script side
For Shell scripts where Python isn't available and you need to read files under iCloud, prepend a function combining brctl and sleep.
read_resilient() {
local path="$1"
local delays=(2 4 8)
local content
for delay in "${delays[@]}"; do
content=$(cat "$path" 2>/dev/null)
local status=$?
if [ $status -eq 0 ]; then
echo "$content"
return 0
fi
brctl download "$path" 2>/dev/null
sleep "$delay"
done
# 最終試行
cat "$path"
}
The Shell version can't get errno directly, so the judgment is the crude "if cat fails, retry." Since it can't narrow to EDEADLK specifically, there's a risk of pointlessly retrying on permission errors too. Use the Python version wherever possible, and for places that must be Shell, the main line is to avoid reading under Documents/Desktop at all.
Verification flow for the migration (Desktop → content)
After the path move in Fix A, the residual-check commands are:
# Git 管理下のファイルに Desktop/Article が残っていないか
grep -r 'Desktop/Article' ~/dev --include='*.js' --include='*.mjs' --include='*.sh'
# 管理外のスクリプト群も個別に確認
grep -r 'Desktop/Article' ~/.claude/scripts/
Even if the first grep returns zero, you can't relax. Scripts outside Git management (such as the 3 under ~/.claude/scripts/) need to be checked separately. The 9 files covered by commit a1b37dc this time are 6 JS/MJS and 3 Shell, and all of those are under repository management. The 3 unmanaged ones remain manually updated and exist only locally.
Where I Got Stuck
Stuck ①: I took "Resource deadlock" at face value and hunted for lock contention
The error message was Errno 11: Resource deadlock avoided.
The moment I saw the words "Resource deadlock," I suspected file lock contention between scripts. Is there anywhere using flock()? Are multiple jobs writing the same file simultaneously? Did a process die without SIGTERM and leave a .lock file behind?
After about 30 minutes of investigating, I typed ls -la ~/Desktop/Article/ and noticed the "@ mark." macOS's ls -l@ indicates extended attributes, and dataless files have a distinctive icon. Running xattr -l <file> showed com.apple.icloud.itemName attached, and I found that while stat returned a real value for st_size, there was no local data.
# dataless かどうかを確認するコマンド
brctl status ~/Desktop/Article/article-001.md
# → 出力例: com.apple.clouddocs dataless
Lesson: POSIX errno 11 is used for EAGAIN on Linux, but it's EDEADLK on macOS. The "deadlock" the name implies is a macOS-specific repurposing; the real meaning is "the operation was avoided because there's no local data." Before interpreting an error name literally, check the errno number and the OS documentation.
Stuck ②: I ran brctl download and the next read_text still died with EDEADLK
When I first wrote read_text_resilient, I had the sleep before brctl download.
# 誤った実装(実際に書いた)
if i < len(delays):
time.sleep(delays[i]) # ← 先に待つ
subprocess.run(["brctl", "download", str(path)], capture_output=True)
I wrote it with the mindset of "wait a bit, then nudge the download along with brctl," but this means calling read_text() immediately after requesting the download. brctl download only queues the request, so the data doesn't materialize in an instant.
The symptom was "retrying 3 times and getting EDEADLK every time." Adding logging showed brctl terminating normally (exit 0) while the next read_text returned the same error. I realized I wasn't waiting an appropriate amount of time after brctl, and moved the sleep behind brctl to fix it.
Lesson: brctl download's return value is the success of "queued for download," not "download complete." If you don't leave enough time after calling it, the next read immediately hits EDEADLK.
Stuck ③: I materialized files with brctl and hit EDEADLK again 10 minutes later
During the August 6 response, I materialized all 69 files once with brctl download, ran the automation script manually, and confirmed a clean exit. Then 15 minutes later, when launchd started the next job, EDEADLK came back.
The cause was that I'd left the root problem alone (disk at 98% = 25GB free).
As long as the "disk is under pressure" condition persists, iCloud's optimize-storage will evict materialized files again. Even if you materialize with brctl download, iCloud re-evicts immediately if there's no headroom on disk. CLAUDE.md and the files under wiki/learning/ in the Vault (~/Documents/claude-obsidian/) went dataless 2–3 times within an hour.
Absorbing this with read_text_resilient is strictly a "safety valve on the reading side," not a "resolution of the root cause of disk pressure." Unless you either free up disk space or exclude the target folder from iCloud sync, the re-eviction won't stop.
As a response, I stopped the ComfyUI server (reclaiming 27GB) and cleaned up orphaned processes (including their on-disk temp files), bringing the disk back into the 60GB-free range, and re-eviction stopped. But that was a byproduct of responding to a different failure the same day, and the reflection that remains is "I forgot to include disk management in the permanent fix."
Lesson: EDEADLK countermeasures need both "retry on the reading side (Plan B)" and "change where the files live (Plan A)." Add Plan B without Plan A and Plan B gets worn down every time the disk fills up.
Stuck ④: grep confirmed zero hits, but 3 files remained
I confirmed zero hits with grep -r 'Desktop/Article' ~/dev and landed commit a1b37dc. The next day, I discovered a different job failing on a Desktop/Article path.
The culprits were 3 Shell scripts under ~/.claude/scripts/. That directory is outside ~/dev/ and isn't under Git management either. Because grep's search scope was narrowed to ~/dev, it didn't match at all.
The leftover symlink was doing its job so there was no actual damage, but a situation where "no error appears because the symlink is alive" hides problems from view. The jobs die the instant you delete the symlink, so migration completion has to be verified by "the path is rewritten directly and it works," not "it works through the symlink."
# 確認コマンドは scope を広く取る
grep -r 'Desktop/Article' ~ \
--exclude-dir=.git \
--include='*.sh' --include='*.py' --include='*.js' --include='*.mjs'
Searching from ~ picks up ~/.claude/scripts/ and ~/dev/ in one pass. Narrow to ~/dev and you miss things.
Lesson: Residual checks after a path replacement should grep at the widest scope, including scripts outside Git management. Script the check command itself so you can reuse it for the next migration.
Stuck ⑤: The Vault's CLAUDE.md was dataless, so my session started with empty context
~/Documents/claude-obsidian/wiki/CLAUDE.md is configured to load automatically at session start. On this day I launched Claude Code while the file was in a dataless state, and the session began with none of the Vault context injected.
No error appeared. There was no notification anywhere saying "the file couldn't be read," and I didn't notice until well into the session. After about 3 exchanges, something felt off — "the Vault information isn't being referenced" — and I materialized it with brctl download and restarted.
This is a different layer from read_text_resilient. Claude Code's config file loading doesn't go through my custom Python function, so it fails silently on EDEADLK and the session comes up with empty context.
The permanent fix would be "don't keep it under ~/Documents/," but given the Vault's nature it needs iCloud sync, so I can't move it. My current operational workaround is a script in launchd's login items that materializes the whole Vault with brctl download -R ~/Documents/claude-obsidian/wiki/ before the session starts.
Lesson: Silent failures from EDEADLK aren't limited to read_text in your scripts — they extend to file loading by tools and frameworks generally. Adding countermeasures to code you wrote isn't enough; if a config file's load path is under iCloud, you also need pre-materialization.
In the next part, I'll write about how this incident response connects to the question "was I actually running a PDCA cycle?" — the alerting design problem that let a stopped lane go undetected for four days, and the structural flaw that made liveness monitoring blind to "silent zero-posting."
Pitfalls
Here's a summary of the traps I fell into during implementation and the design mistakes I caught in code review. They're ordered by how easy they are to fall into, so use this as a pre-implementation checklist.
I read "Resource deadlock" literally and investigated lock contention.
Errno 11: Resource deadlock avoidedis the errno macOS emits when it avoids access to an iCloud dataless file; it has nothing to do with thread or flock deadlocks. Dragged along by the name, I dug throughflockimplementations for 30 minutes. Runningbrctl status <path>first would have ended it in one minute.I put
sleepbefore callingbrctl download.
Following the intuition of "wait a bit, then nudge the download," I wrotesleep→brctl, but this meansread_text()runs immediately afterbrctland hits EDEADLK right away.brctlis a command that "queues the start of a download"; it does not wait for completion. The correct order isbrctl→sleep(2 → 4 → 8 seconds).I materialized files with
brctl downloadand EDEADLK returned 15 minutes later.
Even after materializing once, iCloud re-evicts right away if the disk stays at 98% (25GB free). Symptomatic treatment (brctl) and root treatment (free up disk, move the folder) are separate matters. The same symptom repeated until I reclaimed 27GB by stopping the ComfyUI server and got free space back into the 60GB range.I confirmed zero hits with
grep -r 'Desktop/Article' ~/devand called the migration "done."
There were 3 Shell scripts under~/.claude/scripts/, outside~/dev, and since they're outside Git management they weren't in the search scope. Thanks to the leftover symlink I escaped actual damage, but launchd tripped over it the next day and exposed it. Residual checks should scope from~, as ingrep -r 'Desktop/Article' ~.I verified "migration complete" by the fact that it works through the symlink.
While the~/Desktop/Article→~/content/articlesymlink is alive, old path references still work. The real verification is whether direct paths resolve with the symlink removed. If jobs remain that die the moment you delete the symlink, that becomes a production incident.I confused the errno values of EDEADLK and EAGAIN between macOS and Linux.
On Linux,EAGAINis errno 11 andEDEADLKis errno 35. macOS is the reverse:EDEADLKis 11 andEAGAINis 35. Write magic numbers likee.errno not in (11, 35)and you will get it wrong when porting across OSes. Comparing via the constants —import errnoand useerrno.EDEADLK/errno.EAGAIN— spares you from having to think about OS differences.I retried every
except OSError as e.
PermissionError(errno 13) andFileNotFoundError(errno 2) are never resolved bybrctl download, no matter how many times you retry. Retrying allOSErrors gives you code that waits 14 seconds (2+4+8) on a permission error and then fails again. The correct narrowing is "only on EDEADLK or EAGAIN."I swallowed EDEADLK and returned an empty string.
An empty-string fallback passes tests in the sense that "no error occurs." But the caller interprets it as "0 articles," returnsexit 0, and the liveness check passes as "healthy." This is the root reason four days of zero posts from 8/3 to 8/6 went undetected. If it still fails after the final retry,raisetheOSErroras-is.I omitted
capture_output=Trueand polluted the launchd logs.
brctl downloadwrites text to standard output whether it succeeds or fails. launchd-managed jobs write stdout to logs under/tmp/, sobrctl's output gets mixed in and breaks parsing. As a rule, don't omitcapture_output=True.The config file (
CLAUDE.md) went dataless and no error appeared.
Launch Claude Code while~/Documents/claude-obsidian/wiki/CLAUDE.mdis dataless and the session comes up with empty context. No error message appears. I noticed after 3 exchanges when something felt off — "this isn't being referenced." Addingread_text_resilientto the Python you wrote isn't enough; if config files read by tools or frameworks are under iCloud, you need separate pre-materialization.I hard-coded
attempts=4and the test took 14 seconds.
Burning through all retries runs 2+4+8=14 seconds of sleep. Ifattemptsisn't a parameter, you can't cut the sleep in tests even withmock. Designing it to be overridable —read_text_resilient(path, attempts=1)— lets tests run withattempts=1and skip the sleep.The Shell version pointlessly retried on permission errors too.
In bash you can't get errno from$?(the exit code), so the code tends to retry on anycatfailure. It'll retry 3 times waiting 2+4+8 seconds even on aPermissionError. Minimize the places Shell scripts read under Documents/Desktop, and replacing them with the Python version wherever possible comes first.
Best Practices
Operational rules established from the implementation and the incident response. The principle of not creating "silent failures" runs through all of them.
1. Don't make Desktop or Documents the read/write target of automation
This is the most fundamental countermeasure. On a machine with iCloud Drive enabled, Desktop and Documents are sync targets, and when the disk gets tight, optimize-storage automatically evicts the data. Put files that automation reads and writes on non-iCloud-managed paths under ~/content/ or ~/dev/.
The migration procedure is simple. Move the data from ~/Desktop/Article/ to ~/content/article/ and leave a symlink at the original path. launchd jobs can read through the symlink, so they don't stop mid-migration. Updating path constants is a one-line replacement inside each script. Run the residual check at the widest scope.
grep -r 'Desktop/Article' ~ \
--exclude-dir=.git \
--include='*.sh' --include='*.py' --include='*.js' --include='*.mjs'
Narrowing to just ~/dev misses areas outside Git management, like ~/.claude/scripts/.
2. Always wrap reads of Documents/Desktop in read_text_resilient
For places where automation reads from folders that need iCloud sync (an Obsidian Vault, etc.), Plan A alone doesn't solve it. As Plan B, give the reading side retry tolerance. The final form of the function is as shown in the earlier part; here are the checkpoints.
- Retry only on
errno.EDEADLK/errno.EAGAIN(everything elseraises immediately) -
sleepafter callingbrctl download(not before) - Don't forget
capture_output=True - After all retries fail,
raisetheOSErroras-is (don't return an empty string) - Make
attemptsa parameter so tests can override it
After replacing the Vault .md read sites with read_text_resilient, confirm all 51 existing tests pass.
3. Don't build silent fallbacks
In automation, a "silent failure" is far worse than a loud one. Swallow EDEADLK and return "", and the posting script returns exit 0 on the grounds that "there were 0 articles today." No alert fires and the liveness check passes as "healthy." You don't notice until you open the note dashboard — and I actually didn't notice for four days.
A fallback's "cleverness" reduces noise, but it erases signal at the same time. EDEADLK is an unambiguous state: "the data isn't local right now." If retrying doesn't get through, then either "the environment is broken" or "the file is the problem," and that judgment should be delegated upward. Not swallowing exception responsibility inside the function is what leads to early detection of failures.
4. Compare errno via constants
# 悪い
if e.errno not in (11, 35):
raise
# 良い
if e.errno not in (errno.EDEADLK, errno.EAGAIN):
raise
On macOS, errno.EDEADLK = 11 and errno.EAGAIN = 35. Linux is the reverse. Compare via constants and you don't have to keep OS differences in mind.
5. Add free disk space to your liveness metrics
The root cause here was a single fact: "the disk had filled to 98% (25GB free)."
df -h ~
One command shows it. If you add disk usage to the liveness monitoring of launchd jobs and design it to alert above 80%, you can catch it before iCloud eviction begins. Identifying and stopping the large consumers (this time, a ComfyUI server resident at 27GB) is what disk management actually looks like in practice.
6. Materialize the Vault with brctl download -R before starting a session
Even if CLAUDE.md or hot.md under the Vault go dataless, no error appears when Claude Code launches. The session silently starts with empty context. Put the following in your login items (launchd's StartOnMount or a Shell script).
brctl download -R ~/Documents/claude-obsidian/wiki/
-R is the recursive download option. Making it a procedure to materialize the whole Vault in one go before launching Claude Code prevents the silent failure of "context wasn't injected."
7. Don't trust brctl's return value for retry decisions
brctl download is a command that returns "was the download queued." exit 0 is not download "complete." Controlling retries by subprocess.run's return value is meaningless; the only basis for judgment is whether the next read_text() succeeds. The current implementation doesn't check brctl's return value either (it's discarded via capture_output=True). That's correct as a design.
8. Verify migration completion by "works with the direct path," not "works through the symlink"
The reason for leaving a symlink at ~/Desktop/Article is a safety valve for the migration period. While the symlink is alive, old paths still work, so passing tests doesn't mean the migration is complete. Real completion verification is manually running every job with the symlink deleted. If jobs die after the symlink is removed, those are the un-updated path references.
9. Extend residual checks for Shell scripts beyond Git management
Confirming zero hits with a grep inside the Git repository doesn't reach unmanaged scripts under ~/.claude/scripts/. Script the residual check command itself and widen the scope to all of ~, so you can reuse it for the next migration.
#!/bin/bash
# path-check.sh — Desktop/Article が残存していないか確認
grep -r 'Desktop/Article' ~ \
--exclude-dir=.git \
--include='*.sh' --include='*.py' --include='*.js' --include='*.mjs' \
--include='*.mjs'
Zero lines returned means "no residuals" is confirmed.
10. Have a triage pattern for "everything is down" ready in advance
Even when the symptom looks like the same "all lanes stopped," there are multiple root causes. In just these 2 days, three kinds ran in parallel: "resource leak (692 orphaned processes, ComfyUI resident at 27GB)," "EDEADLK from iCloud dataless files," and "weekly quota exceeded."
The triage order is fixed.
① Look at the error code. EDEADLK means a file problem. Timeout means a process/memory problem. You've hit your weekly limit means quota. Error messages don't lie.
② Look at what is not dying at the same time. With an iCloud problem, python3 open() dies but curl lives. With a memory problem, both Chrome and claude -p die. The breadth of the failure narrows down the nature of the cause.
③ Check free disk space with df -h ~. At 80% or below, iCloud dataless eviction is unlikely to happen. If you were under pressure, it's the same path as this incident.
Without this flow, you'll burn an hour on root-cause identification every time.
11. Measure liveness by actual post count. Don't just look at exit 0
Even when files are dataless, many scripts return exit 0 on the grounds that "there were 0 articles." Confirming in liveness monitoring that "the process terminated normally" doesn't detect zero posting.
What you should monitor is "were there 1 or more posts to this platform today?" Aggregate the actual same-day post count per platform — note, X, Instagram, etc. — and design it to alert when zeros continue. The more revenue a lane produces, the more indispensable this monitoring is.
12. Don't forget to parameterize attempts
def read_text_resilient(path: Path, attempts: int = 4) -> str:
The production default is attempts=4 (initial attempt + 3 retries, 14 seconds of sleep total), but in tests you override it to attempts=1 to skip the sleep. An implementation without the parameter leaves you with two options in tests: wait the full 14 seconds, or completely replace sleep with unittest.mock. Being able to control it with an argument is overwhelmingly easier.
13. Attach priority-demotion keys to every plist
Broadening slightly from the iCloud topic. During this incident response, I discovered that 18 heavyweight Chrome jobs were missing Nice=10 / LowPriorityIO=true / ProcessType=Background. Jobs without priority set are disadvantaged in the kernel's resource allocation, which was one factor in Chrome startup hitting the 180-second timeout.
By detecting plist convention violations in bulk with a lint and adding the keys to all 18 before reloading, I confirmed 157/157 compliance and 0 load failures in launchctl list. The more jobs an environment has, the more important it is to build the lint into CI to prevent convention violations from creeping in.
Summary
The EDEADLK caused by iCloud's optimize-storage is neither a bug in my code nor a bug in macOS. It's the structural problem that macOS's design — "when the disk gets tight, evict files under Desktop and Documents to iCloud" — triggers a silent, fatal error for automation scripts.
The reason I let the note lane go to zero posts for four days starting August 3 isn't that I didn't know about this mechanism — it's that for four days I didn't notice a silent failure was happening. The scripts were returning exit 0. Liveness monitoring was returning "healthy." Until I opened the note dashboard, nothing looked broken.
The permanent fix has two pillars.
A. Move the files out. Get automation's read/write targets out of Desktop and Documents. I moved 796 article files to ~/content/article/ and left a symlink so jobs didn't stop mid-migration. Put them on a non-iCloud-managed path and they don't get evicted even under disk pressure.
B. Absorb EDEADLK on the reading side. For places that read folders needing iCloud sync (the Vault, etc.), interpose read_text_resilient(). Only on EDEADLK/EAGAIN, run brctl download → retry with 2 → 4 → 8 second exponential backoff, and raise any other OSError immediately. On final failure, throw the exception up as-is.
These two have different roles. A "builds a structure where eviction doesn't happen"; B "adds a safety valve so you can still read when eviction does happen." Add B without A and B gets worn down every time the disk fills. Add A without B and you're defenseless in folders like the Vault that can't be moved. Neither one alone is complete.
What protects an automation line isn't only the 160+ launchd jobs. You need a guarantee that the files those jobs read haven't silently vanished. This incident was an experience that made me re-examine my trust in the filesystem from the ground up.
Written by **Lily* — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*
Top comments (0)