Skip to content

Build1 publisher3 min readPublished

Exit 0 is not a health check: a dead PID held a lock for two hours and nothing complained

A scheduled Claude Code job skipped three runs in a row and reported success every time. The cheap fixes: check whether the PID in the lock file is alive, and alert on output rather than status.

The Engineer · Build desk

Drafted by a language model from the sources cited here and checked against its claim ledger before publication. How we use AISend a correction

What happened

  • The author reports that on a Claude Code autonomous environment, three consecutive launchd runs generated zero pieces of content, each with last exit=0, with no alert, over about two hours.
  • The cause was process pid 94799, already killed, still holding the lock file ~/dev/brand-404/sns/gen_work/.lock; every subsequent run was skipped with a 'lock held' message and generation stopped for about two hours.
  • gen_feature.py line 57 sets LOCK_STALE_SEC = 2 * 3600, a safety valve allowing a run to steal the lock if the lock file's mtime is older than two hours.
  • The original implementation only checked the lock file's mtime and did not check whether the recorded pid was alive, so a killed pid with a recent mtime kept being read as 'still running'.
  • The stale threshold of 2 * 3600 equals 7,200 seconds, so the maximum silent outage was bounded by the safety valve rather than by detection.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

A scheduled content generator on one operator's Claude Code setup produced nothing for roughly two hours while reporting success: three consecutive launchd runs, zero items generated, exit 0 every time, and not one alert [1]. The cause, per the writeup, was a process that had already been killed but whose pid, 94799, was still sitting in the lock file at `~/dev/brand-404/sns/gen_work/.lock`, so every later run concluded a job was in flight and exited cleanly [2].

The script does have a stale-lock escape hatch. Line 57 sets `LOCK_STALE_SEC = 2 * 3600`, which permits a run to steal the lock if the file's mtime is older than two hours [3]. The original implementation looked only at mtime and never asked whether the recorded pid was alive [4]. That means the safety valve, not the monitoring, defined the outage: 7,200 seconds of skipped runs, ending because a timer expired rather than because anything noticed the corpse [5].

Orphaned locks are the normal case here, not an exotic one. A SIGKILL skips the `finally` block, so `release_lock()` never runs, and the same thing happens when you stop a job by hand during development [6].

The lock is there for a real reason. A single run makes up to three `claude -p` calls under a MAX plan quota, with paid APIs excluded, plus image generation, and takes tens of minutes; `CLAUDE_TIMEOUT` is 600 seconds [7][17]. Three calls at that ceiling is 30 minutes of model timeout budget alone, before downloads [8]. Without mutual exclusion, a scheduled restart can overlap an unfinished run, double-generate the same brand, and produce concurrent writes [9]. The pipeline in between is not trivial either: queue check, brand selection from `brand-catalog.json`, a Shopify `/products.json` fetch, copy generation, image role selection, slide build, self-QA, then output to a feature directory and lock release [16].

The reason two hours passed unnoticed is the buffer. `QUEUE_TARGET = 3` and `MAX_GEN_PER_RUN = 2`, and the author's stated intent is that three items in stock keep posting alive through a day or two of failed generation [10]. A separate job posts one item daily at 19:30 through the Instagram Graph API and logs it to `state/ig_posted.jsonl` [11]. Three queued items against one post per day is roughly three days of cover [12]. The inventory that protects the visible output is the same thing that conceals the broken half of the system.

The stakes are the author's own and unaudited: six months of building, and a claim that the pipeline now supports JPY 1.2M a month in sales unattended [13], against an earlier manual stretch of 30 straight days of hand-posting at JPY 100k a month that ended in burnout [14]. The generalisable part is the framing: a script is correct when written, and time turns dependencies, dead processes and leftover lock files into debris that blocks every future run, so an autonomous setup needs a repair layer [15].

Two things to check in your own scheduler this week. First, whether lock acquisition tests pid liveness rather than file age alone, because age-only logic converts a crash into a silent timed outage [4][5]. Second, whether your alerting keys on artifacts produced rather than process status, since three runs exiting 0 delivered three consecutive false all-clears [1][18]. A queue depth that has not moved is a signal; a return code from a process that decided to do nothing is not.

Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories