A Dead PID Held My Lock for 2 Hours: One Missing Line, Zero Output, exit 0 Every Time A developer's autonomous Instagram posting system, built with Claude Code, silently failed for two hours when a dead process held a lock file, causing zero content generation despite exit code 0. The issue was traced to a lock check that only examined file modification time without verifying the process ID was alive. The developer fixed it by adding a PID liveness check, ensuring the system that supports ¥1.2M/month in revenue runs reliably. For 30 straight days as a college student earning ¥100k/month, I posted to Instagram by hand, and then I burned out and stopped. Today the same job runs on a Claude Code autonomous environment, I touch nothing, and it holds up ¥1.2M/month in revenue. Except for the two hours when it quietly stopped: three consecutive launchd runs, zero pieces of content generated, last exit=0 every single time, and not one alert. The cause was a process that had already been killed, holding a lock file nobody would take away from it. The problem with updating social media by hand is that it burns willpower. No matter how motivated you are, sleep, health, and mood all fluctuate. During the period when I was laid off and my income went to zero, I had no mental slack for posting at all. The autonomous environment I spent six months building with Claude Code runs regardless of my emotional state. launchd calls a script, the script generates content with claude -p MAX plan quota; paid APIs are off-limits , the output is queued for auto-posting, and it goes out to Instagram every day at 19:30. As long as this machinery keeps working, ¥1.2M/month in sales holds up without me lifting a finger. A lot of people think "automation = writing scripts," and that's only half right. A script is correct at the moment you write it. Given time, external dependencies break, processes die for reasons you didn't anticipate, and lock files turn into debris that blocks every future run. An autonomous environment that actually works is one that assumes breakage and carries a layer that repairs it. The lock story here is a textbook case. ~/dev/brand-404/sns/gen feature.py is a script launched on a schedule by launchd that auto-generates Instagram feature articles. A single run takes a long time up to three claude -p calls, plus image generation, adding up to tens of minutes , so it has a lock mechanism to prevent the next run from overlapping with one that hasn't finished. Mishandling that lock file ~/dev/brand-404/sns/gen work/.lock meant that pid 94799 held the lock without releasing it even though it had already been killed , every subsequent run was skipped with "lock held — 終了", and generation stopped for about two hours. The lock check has a constant LOCK STALE SEC = 2 3600 2 hours line 57 of gen feature.py . It's a safety valve: "even if the lock exists, steal it if the mtime is older than 2 hours." But the original implementation only looked at mtime, without checking whether the pid was alive. Even with a killed pid sitting in .lock , as long as the mtime was within 2 hours it kept deciding "still running" and skipping. launchd could fire a third and a fourth time — all of them "lock held — 終了" until the two hours elapsed. The worst possible state — automation that is "running" but produces nothing — continued silently for two hours. With manual work, you notice: "huh, nothing got generated today," and you run it by hand. But building an autonomous environment means taking on responsibility for it continuing to work correctly while nobody is watching . A pid liveness check looks like belt-and-suspenders, but in practice "the script gets killed and the lock stays behind" happens routinely. If you kill it instantly with SIGKILL, the release lock in the finally block never runs at all. Same thing when you stop it by hand during development. If you're scheduling a long-running script with launchd, a pid liveness check is mandatory. launchd 毎日定時 + 毎日19:30 │ ├─ gen feature.py(毎日定時) │ acquire lock ← 今回の話 │ ↓ │ キュー残数チェック │ QUEUE TARGET=3 に不足があれば │ ↓ │ brand-catalog.json からブランド選定 │ ↓ │ Shopify /products.json 取得 │ ↓ │ claude -p 1: コピー生成 copy.md │ ↓ │ claude -p 2: 画像役割選定 images.md │ ↓ │ スライド生成 build-post-from-json.mjs │ ↓ │ claude -p 3: セルフQA qa.md │ ↓ QAを通過 │ content/sns/feature-XX-{slug}/ に出力 │ release lock │ └─ ig autopost.py(毎日19:30) content/sns/feature- のキューから1本取り出し ↓ Instagram Graph API で投稿 ↓ state/ig posted.jsonl に記録 The queue targets a standing inventory of 3 items gen feature.py line 60, QUEUE TARGET = 3 ; line 61, MAX GEN PER RUN = 2 . With 3 in stock, posting doesn't break even if generation fails for one or two days in a row. When you manage a script with a long single run up to CLAUDE TIMEOUT = 600 seconds × 3 calls, plus image downloads via scheduled launchd starts, these problems appear: brand-catalog.json corrupt it gen work/.lock is what prevents that. The process writes its own PID into the lock file, and on the next start the script checks whether that PID is alive before deciding whether to run. Lines 225–249 of gen feature.py are the whole thing. php LOCK STALE SEC = 2 3600 57行目 def acquire lock - bool: GEN WORK.mkdir parents=True, exist ok=True if LOCK FILE.exists : age = time.time - LOCK FILE.stat .st mtime try: pid = int LOCK FILE.read text encoding="utf-8" .strip if pid <= 0: raise ValueError except OSError, ValueError : log "stale lock 奪取: PIDが空または非数値" else: try: os.kill pid, 0 ← プロセス生死チェック except ProcessLookupError: log f"stale lock 奪取: pid={pid} は不在" except PermissionError: if age <= LOCK STALE SEC: return False log "stale lock 奪取: mtime 2h超" else: if age <= LOCK STALE SEC: return False log "stale lock 奪取: mtime 2h超" LOCK FILE.write text str os.getpid , encoding="utf-8" return True The decision logic before the fix the broken code only looked at mtime. php ❌ 修正前: pid の生死を見ない def acquire lock - bool: GEN WORK.mkdir parents=True, exist ok=True if LOCK FILE.exists : age = time.time - LOCK FILE.stat .st mtime if age <= LOCK STALE SEC: return False ← kill済みpidでも2h以内は全部ここで返る log "stale lock 奪取: mtime 2h超" LOCK FILE.write text str os.getpid , encoding="utf-8" return True os.kill pid, 0 sends signal 0 the null signal . Signal 0 doesn't actually send anything. The kernel just checks the pid and returns: success if the process exists and you have permission to signal it, ProcessLookupError if the process doesn't exist, and PermissionError if the process exists but belongs to another user. Using this behavior, you can safely check whether a process is alive. | Result of os.kill pid, 0 | Meaning | acquire lock's decision | |---|---|---| | No exception | The process for that pid exists and is running | If mtime is within 2h, treat as "running" and skip | | ProcessLookupError | The process for that pid is gone killed, etc. | Stale lock — steal it immediately | | PermissionError | The pid exists but is owned by another user | mtime fallback steal if older than 2h | In this incident, where pid 94799 had been killed, the fixed code would raise ProcessLookupError , immediately log "stale lock 奪取: pid=94799 は不在" , and let the next runner take the lock. The same pattern is used in the multi-start guard of ~/.claude/scripts/automation-health.sh lines 20–30 . ah lock="${TMPDIR:-/tmp}/automation-health.lock" if mkdir "$ ah lock" 2 /dev/null; then if kill -0 "$ cat "$ ah lock/pid" 2 /dev/null " 2 /dev/null; then echo "automation-health: 別インスタンス稼働中のためスキップ" &2 exit 0 fi rm -rf "$ ah lock" mkdir "$ ah lock" 2 /dev/null || { echo "lock取得失敗・スキップ" &2; exit 0; } fi echo $$ "$ ah lock/pid" trap 'rm -rf "$ ah lock"' EXIT The comments lines 17–20 spell out the reasoning: " --deep scans everything, so I/O piles up and load average spikes if multiple instances run. An atomic mkdir lock narrows it to one. The PID liveness check auto-steals stale locks , and trap EXIT guarantees release on both normal and abnormal termination." The shell's kill -0