<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Lily</title>
    <description>The latest articles on DEV Community by Lily (@bokuwalily).</description>
    <link>https://dev.to/bokuwalily</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4024286%2Fdf479f07-d3b3-4271-8cc4-8976224e437e.png</url>
      <title>DEV Community: Lily</title>
      <link>https://dev.to/bokuwalily</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/bokuwalily"/>
    <language>en</language>
    <item>
      <title>4 Days, 3 Wasted Calls Per Run: My Retry Loop Mistook a Quota-Limit Message for a 'Too Short' Article</title>
      <dc:creator>Lily</dc:creator>
      <pubDate>Mon, 21 Sep 2026 05:00:05 +0000</pubDate>
      <link>https://dev.to/bokuwalily/4-days-3-wasted-calls-per-run-my-retry-loop-mistook-a-quota-limit-message-for-a-too-short-4l23</link>
      <guid>https://dev.to/bokuwalily/4-days-3-wasted-calls-per-run-my-retry-loop-mistook-a-quota-limit-message-for-a-too-short-4l23</guid>
      <description>&lt;p&gt;Last time I wrote about &lt;a href="https://dev.to/bokuwalily/2-pitfalls-in-priority-probes-letting-one-real-request-through-an-open-circuit-breaker-4km5"&gt;using a probe to decide which jobs to stop first when the quota runs dry&lt;/a&gt;. This post is about the problem sitting right next to it: my script &lt;em&gt;could&lt;/em&gt; tell when the quota was exhausted, but it treated the limit message as if it were real article body text, then kept retrying a failure that no rewrite could ever fix. From September 13 to 16, every run burned 3 &lt;code&gt;claude -p&lt;/code&gt; calls to produce nothing. After the fix, it burns 1 and bails out.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: 4 straight days (9/13–9/16) of 3 failures per run
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;note2-daily-stock.sh&lt;/code&gt; auto-generates product review articles for note2 (bokumolily) using &lt;code&gt;claude -p&lt;/code&gt;. For each product it calls &lt;code&gt;claude -p&lt;/code&gt; up to 3 passes to produce the body. Each pass is judged by a quality check (&lt;code&gt;article_quality_ok&lt;/code&gt;) with criteria like "does the character count exceed MIN_CHARS (7,000)?" and "are there at least 2 affiliate links?"&lt;/p&gt;

&lt;p&gt;For 4 days, 9/13 through 9/16, this script failed 3 times in a row with the exact same symptom. The cause: when &lt;code&gt;claude -p&lt;/code&gt; hits the Max plan's limit, it returns a &lt;strong&gt;one-line limit-reached message&lt;/strong&gt; (no &lt;code&gt;TITLE:&lt;/code&gt; line), and the script treated that as "an ordinary generation failure where the body is too short." The comment in the actual code records exactly what happened:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;  &lt;span class="c"&gt;# 2026-09-16: claude -pがクォータ上限メッセージ(1行・TITLE:行なし)を返すと&lt;/span&gt;
  &lt;span class="c"&gt;# 本文が空のまま「文字数不足」として3回とも空振りし、上限中に3回分のclaude呼び出しを浪費していた&lt;/span&gt;
  &lt;span class="c"&gt;# (note2-daily 9/13-9/16 4日連続で同一症状を実測)。上限メッセージは即検出して1回で中断する。&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(note2-daily-stock.sh:565-567)&lt;/p&gt;

&lt;p&gt;While the limit is in effect, no amount of rewriting will produce a body. Yet &lt;code&gt;article_quality_ok&lt;/code&gt; only ever said "quality NG reason: not enough characters," so the script concluded "maybe one more pass will fix it" and threw away 2 more pointless &lt;code&gt;claude -p&lt;/code&gt; calls during the limit window.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; A retry loop needs more than the monolithic fact that "it failed." &lt;strong&gt;If you don't distinguish "a failure a rewrite can fix" from "a failure a rewrite cannot fix," you'll keep walking into the same wall.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The fix: grep for the limit message and abort after 1 pass
&lt;/h2&gt;

&lt;p&gt;The fix is to grep for the limit-message pattern immediately after each pass's generation, and if it matches, abort right there instead of spending the remaining passes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;  &lt;span class="k"&gt;for &lt;/span&gt;pass &lt;span class="k"&gt;in &lt;/span&gt;1 2 3&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
    &lt;/span&gt;&lt;span class="nv"&gt;RAW&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;generate_article_raw &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PRODUCT_JSON&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IMG_COUNT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RAW&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-qiE&lt;/span&gt; &lt;span class="s2"&gt;"hit your (weekly |5-hour |usage |session )?limit|usage limit reached|rate limit|resets? (at |in |on )?[0-9]{1,2}(:[0-9]{2})? *[ap]m"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
      &lt;/span&gt;&lt;span class="nv"&gt;QUOTA_HIT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
      log &lt;span class="s2"&gt;"ABORT: quota-message検出のため中断 (pass &lt;/span&gt;&lt;span class="nv"&gt;$pass&lt;/span&gt;&lt;span class="s2"&gt;) &lt;/span&gt;&lt;span class="nv"&gt;$SLUG&lt;/span&gt;&lt;span class="s2"&gt; raw先頭=[&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RAW&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="s1"&gt;'\n'&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt; | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-c1-160&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;]"&lt;/span&gt;
      &lt;span class="nb"&gt;break
    &lt;/span&gt;&lt;span class="k"&gt;fi
    &lt;/span&gt;write_article_file &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RAW&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$OUT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    ...
  &lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(note2-daily-stock.sh:563-572, excerpt)&lt;/p&gt;

&lt;p&gt;When &lt;code&gt;QUOTA_HIT&lt;/code&gt; is set, the script deletes the half-written output file and any images downloaded along the way, notifies Discord, and exits immediately. Instead of 3 calls, it consumes exactly 1 at that point and retreats.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$QUOTA_HIT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-eq&lt;/span&gt; 1 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
    &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$OUT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$OUT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ROOT&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;IMG_BASENAME&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-p"&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;.jpg
    discord_notify &lt;span class="s2"&gt;"⚠️ note2-daily: claudeクォータ上限のため中断（3回消費せず中止）: &lt;/span&gt;&lt;span class="nv"&gt;$ITEM_NAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="nb"&gt;exit &lt;/span&gt;1
  &lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(note2-daily-stock.sh:590-595)&lt;/p&gt;

&lt;p&gt;"Not enough characters" and "limit reached" look alike on the surface (the body is nearly empty), but the correct response is completely different. The former might be fixed by a rewrite; the latter won't be fixed no matter how many times you retry until the reset time. Separating those two cases up front with a single grep is the entire fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pitfall I stepped in: the sibling scripts still don't have it
&lt;/h2&gt;

&lt;p&gt;There are 3 other scripts that share the same shape as note2-daily-stock.sh — "have &lt;code&gt;claude -p&lt;/code&gt; write the body, retry if the quality check fails": &lt;code&gt;article-daily-stock.sh&lt;/code&gt; (the script that writes this very Zenn article series), &lt;code&gt;maker-daily-stock.sh&lt;/code&gt;, and &lt;code&gt;series-daily-stock.sh&lt;/code&gt;. I grepped all 3, and none of them contained the limit-message detection pattern.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"hit your&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;usage limit&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;QUOTA_HIT&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;rate limit"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    article-daily-stock.sh maker-daily-stock.sh series-daily-stock.sh
&lt;span class="o"&gt;(&lt;/span&gt;該当なし&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In particular, &lt;code&gt;run_pass()&lt;/code&gt; in &lt;code&gt;series-daily-stock.sh&lt;/code&gt; has almost exactly the same structure that note2 tripped over.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;run_pass&lt;span class="o"&gt;(){&lt;/span&gt; &lt;span class="c"&gt;# $1=prompt $2=outfile $3=label&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;prompt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nv"&gt;outfile&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$2&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nv"&gt;label&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$3&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; attempt &lt;span class="nb"&gt;wait
  &lt;/span&gt;&lt;span class="k"&gt;for &lt;/span&gt;attempt &lt;span class="k"&gt;in &lt;/span&gt;1 2 3&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
    &lt;/span&gt;&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$outfile&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="nb"&gt;timeout &lt;/span&gt;600 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CLAUDE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; ... &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$prompt&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; ... &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$outfile&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; ...
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$outfile&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
      ...
    &lt;span class="k"&gt;fi
    &lt;/span&gt;log &lt;span class="s2"&gt;"WARN: pass &lt;/span&gt;&lt;span class="nv"&gt;$label&lt;/span&gt;&lt;span class="s2"&gt; 空出力(attempt &lt;/span&gt;&lt;span class="nv"&gt;$attempt&lt;/span&gt;&lt;span class="s2"&gt;/3)"&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$attempt&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-lt&lt;/span&gt; 3 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
      &lt;/span&gt;&lt;span class="nb"&gt;wait&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;attempt &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt;&lt;span class="k"&gt;))&lt;/span&gt;
      &lt;span class="nb"&gt;sleep&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$wait&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;fi
  done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(series-daily-stock.sh:254-279, excerpt)&lt;/p&gt;

&lt;p&gt;Waiting 30 seconds, then 60 seconds, on "empty output (attempt N/3)" and &lt;strong&gt;hammering the same call 3 times for the same reason&lt;/strong&gt; is essentially the same structure note2 hit on 9/13–9/16. If this script runs during a limit window, it should waste calls in exactly the same way — and it hasn't been fixed yet.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;maker-daily-stock.sh&lt;/code&gt; only has a single generation pass, so it doesn't fail 3 in a row, but the same dates show up in its comments too.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;  &lt;span class="c"&gt;# 失敗時もキューを1件進める。進めないと同じ題材が先頭に居座り続け、翌日以降も&lt;/span&gt;
  &lt;span class="c"&gt;# 同じ理由で落ち続ける（2026-09-13〜16 に同一題材が7回連続で失敗した実測）。&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(maker-daily-stock.sh:412-413)&lt;/p&gt;

&lt;p&gt;In other words, the Max plan limit on 9/13–9/16 didn't just hit note2 — it dragged the maker pipeline down with it. On the maker side, the underlying problem of "misdiagnosing the limit message as an incomplete body" is still there, exactly as in note2; it's being held together by a different band-aid: "even on failure, at least advance the topic queue." &lt;strong&gt;The same root cause has a different emergency patch in each script, applied whenever someone happened to notice.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;I confused a one-line limit message with a "body too short" quality failure&lt;/strong&gt; → grep for it right after generation and abort &lt;em&gt;before&lt;/em&gt; the quality check&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Judging retries only by "empty/non-empty" or "character count" can't tell failure types apart&lt;/strong&gt; → branch on the failure reason (retry the fixable ones, exit immediately on the unfixable ones)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Even when one limit drags down multiple pipelines at once, the fix doesn't propagate&lt;/strong&gt; → note2 detects the cause, maker advances the queue, article/series are untouched — the responses are still all over the place&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fixing one script doesn't automatically carry over to sibling scripts that copied the same structure&lt;/strong&gt; → don't assume "it's fixed" until you've confirmed it with grep&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;For 4 days (9/13–9/16), note2-daily-stock.sh misdiagnosed the limit-reached message as "not enough characters" and burned all 3 pointless &lt;code&gt;claude -p&lt;/code&gt; calls during the limit window&lt;/li&gt;
&lt;li&gt;The fix greps for the limit-message pattern immediately after generation and, on a match, aborts after 1 pass, ahead of the quality check&lt;/li&gt;
&lt;li&gt;During the same 4 days, &lt;code&gt;maker-daily-stock.sh&lt;/code&gt; also failed 7 times in a row on the same topic — the limit hit multiple pipelines simultaneously. But maker's fix is only "advance the queue," and the underlying misdiagnosis remains unfixed there as well as in &lt;code&gt;article-daily-stock.sh&lt;/code&gt; and &lt;code&gt;series-daily-stock.sh&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A retry loop that doesn't distinguish failure types (fixable vs. unfixable) will step in the same hole every time.&lt;/strong&gt; A lesson learned in one place only pays off once it's propagated to other scripts with a similar structure&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next time I plan to write about whether that propagation itself can be automated — a mechanism to mechanically carry a fix found in one script over to its "structurally similar siblings."&lt;/p&gt;

&lt;p&gt;How do your retry loops tell a fixable failure from one that no retry will ever fix?&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by **Lily&lt;/em&gt;* — I ship iOS apps and automate my content stack with Claude Code.&lt;br&gt;
Follow along: &lt;a href="https://bokuwalily.com" rel="noopener noreferrer"&gt;Portfolio&lt;/a&gt; · &lt;a href="https://x.com/bokuwalily" rel="noopener noreferrer"&gt;X&lt;/a&gt; · &lt;a href="https://github.com/bokuwalily" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;*&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>bash</category>
      <category>automation</category>
      <category>llm</category>
    </item>
    <item>
      <title>One Line of mv Kills the 4x Duplicate Run After Recovery: Making launchd Queue Processing Idempotent</title>
      <dc:creator>Lily</dc:creator>
      <pubDate>Mon, 21 Sep 2026 00:00:06 +0000</pubDate>
      <link>https://dev.to/bokuwalily/one-line-of-mv-kills-the-4x-duplicate-run-after-recovery-making-launchd-queue-processing-idempotent-1jl9</link>
      <guid>https://dev.to/bokuwalily/one-line-of-mv-kills-the-4x-duplicate-run-after-recovery-making-launchd-queue-processing-idempotent-1jl9</guid>
      <description>&lt;p&gt;Six months ago my side business made ¥0 a month. This month it's ¥1.2M, running on an autonomous setup built with Claude Code. And here's the thing about automation: the moment it breaks is exactly when the sloppiness in your design gets exposed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Design Works
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Fork Between "Fixing by Habit" and "Fixing the Environment"
&lt;/h3&gt;

&lt;p&gt;When automation breaks, the reflex is to reach for a procedural fix: "next time I'll do this instead." Check the logs more often. Retry by hand. Space things out a bit more. All of these are bandages meant to shrink the wound the next time the problem recurs. They don't remove the cause, so once the same conditions line up, it will happen again.&lt;/p&gt;

&lt;p&gt;After six months of running an automated system, one thing I'm certain of: &lt;strong&gt;a scheduler combined with a shared directory will always collide unless you explicitly implement mutual exclusion.&lt;/strong&gt; Not "it might collide if you're unlucky," but "it will collide whenever the conditions for collision line up." This isn't a probability problem; it's a structural one.&lt;/p&gt;

&lt;p&gt;On September 17, 2026, Codex was down for an entire day with &lt;code&gt;usage_limit_exceeded&lt;/code&gt;. Under &lt;code&gt;~/.codex/sessions/2026/09/17/&lt;/code&gt; there were 54 sessions piled up, every one of them terminated with a limit error. Throughout that time, three workflows—note-autolike, ai-portraits-fragments, and social-autolike—kept firing jobs, and unprocessed JSON files accumulated in &lt;code&gt;~/dev/note-autolike/done/&lt;/code&gt;. Measured the next morning at 05:20, the JSON files flagged &lt;code&gt;needs_imagegen_thumbnail&lt;/code&gt; had swelled &lt;strong&gt;from 43 to 57&lt;/strong&gt;. Fourteen new files had stacked up, all of them left untouched.&lt;/p&gt;

&lt;p&gt;The instant Codex came back, five launchd lanes fired at once. Each lane's pickup script grabs the JSON files under &lt;code&gt;done/&lt;/code&gt; with &lt;code&gt;ls&lt;/code&gt; or a glob and starts processing. With no mutual exclusion, multiple processes look at the same JSON at the same time. The result: the &lt;code&gt;funnel-pm&lt;/code&gt; lane processed the same JSON &lt;strong&gt;4 times, one minute apart&lt;/strong&gt;. As the numbers show—8 of 11 files were hit 2 or more times—this wasn't a freak accident. It was an inevitable consequence of the design.&lt;/p&gt;

&lt;h3&gt;
  
  
  launchd Doesn't Know About "Fighting Over Files"
&lt;/h3&gt;

&lt;p&gt;Look at &lt;code&gt;StartCalendarInterval&lt;/code&gt; in &lt;code&gt;com.lily.codex-note-funnel.plist&lt;/code&gt; and you'll see two entries: 10:40 and 16:40.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;StartCalendarInterval&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;array&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;dict&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;Hour&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;integer&amp;gt;&lt;/span&gt;10&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;Minute&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;integer&amp;gt;&lt;/span&gt;40&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;dict&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;Hour&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;integer&amp;gt;&lt;/span&gt;16&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;Minute&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
        &lt;span class="nt"&gt;&amp;lt;integer&amp;gt;&lt;/span&gt;40&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/array&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;launchd simply invokes &lt;code&gt;run-codex-funnel.sh&lt;/code&gt; according to this schedule. Whether the previous run finished, or whether another lane is currently working on the same file—launchd has no interest in any of it. And that's correct, by-design behavior. launchd is a scheduler, not a mutex. Mutual exclusion over a file queue is the application layer's responsibility—in other words, it's a feature the pickup script itself has to implement.&lt;/p&gt;

&lt;h3&gt;
  
  
  Backlog Plus Simultaneous Recovery Is What Surfaces the Race
&lt;/h3&gt;

&lt;p&gt;In normal operation, each lane's timing is slightly offset, which naturally keeps collisions rare. Target JSON files also arrive one at a time in sequence, so there are few opportunities for multiple lanes to see the same file simultaneously.&lt;/p&gt;

&lt;p&gt;But a long outage like Codex's &lt;code&gt;usage_limit_exceeded&lt;/code&gt; changes the situation completely. All lanes start at once with 57 JSON files backed up from the outage period.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;通常運転:
  レーンA → job_001 処理 → 完了
  レーンB → job_002 処理 → 完了  （タイミングがズレており競合しない）

Codex 復帰直後:
  レーンA ─┐
  レーンB ─┤──→ job_001 を同時取得 → 4連射
  funnel-pm ─┘
  （57本が溜まっており、全レーンが先頭ファイルに殺到）
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A procedural fix like "adjust the intervals" can't handle this "burst release" pattern. No matter how tightly you tune the intervals, the instant after recovery, every lane will fire simultaneously.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why a Lock File Isn't Enough
&lt;/h3&gt;

&lt;p&gt;"Just create a lock file" is the natural idea. But look closely at the implementation and there's a problem.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# NG: check-then-act は原子的でない&lt;/span&gt;
&lt;span class="nv"&gt;LOCKFILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;~/dev/note-autolike/.processing.lock
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOCKFILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;touch&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOCKFILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    process_file &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$json&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOCKFILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Between the &lt;code&gt;if [ ! -f ]&lt;/code&gt; check and the &lt;code&gt;touch&lt;/code&gt;, there's a "Time of Check to Time of Use" (TOCTOU) window where another process can slip in. Two processes both observe "no file" and both execute &lt;code&gt;touch&lt;/code&gt;—that's the race. Under high load and high frequency, this window really does open.&lt;/p&gt;

&lt;p&gt;On top of that, if the process crashes mid-run, the lock file stays behind. Every subsequent run sees the lock and exits immediately, so processing stalls completely. You need a separate crash-recovery procedure, and operational cost goes up.&lt;/p&gt;

&lt;p&gt;File locking via the &lt;code&gt;flock&lt;/code&gt; command is another option, but since file descriptors aren't inherited each time launchd spawns a new process, it doesn't work for exclusion between lanes.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Guard Philosophy Shown by autolike-plist-reconcile.sh
&lt;/h3&gt;

&lt;p&gt;The existing &lt;code&gt;~/.claude/scripts/autolike-plist-reconcile.sh&lt;/code&gt; contains a guard built with the same concern in mind. That script does a &lt;code&gt;reload&lt;/code&gt; when the &lt;code&gt;AUTOLIKE_TIMEOUT_SEC&lt;/code&gt; value in the plist has drifted from the value launchd has loaded, but the core is this code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;pid&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;launchctl list | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nv"&gt;l&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$L&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s1"&gt;'$3==l{print $1}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$pid&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$pid&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"-"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"[&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="s1"&gt;'+%F %T'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;] &lt;/span&gt;&lt;span class="nv"&gt;$L&lt;/span&gt;&lt;span class="s2"&gt; 実行中(pid=&lt;/span&gt;&lt;span class="nv"&gt;$pid&lt;/span&gt;&lt;span class="s2"&gt;) のため見送り (&lt;/span&gt;&lt;span class="nv"&gt;$have&lt;/span&gt;&lt;span class="s2"&gt; -&amp;gt; &lt;/span&gt;&lt;span class="nv"&gt;$want&lt;/span&gt;&lt;span class="s2"&gt;)"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;continue
fi
&lt;/span&gt;launchctl bootout &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$D&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$L&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The comment at the top of the script says that booting out a running job wipes out that run's likes entirely, so it must always wait. It fetches the PID via &lt;code&gt;launchctl list&lt;/code&gt; and skips the bootout unless the value is &lt;code&gt;-&lt;/code&gt; (not running). Before starting work, check "is someone else already processing this?"—and if so, exit immediately. That's the same idea as the &lt;code&gt;mv&lt;/code&gt; pattern in this article.&lt;/p&gt;

&lt;p&gt;That said, a PID check has limits. You can't reduce to zero the chance that a new process starts between the "check" and the "bootout." In the context where this script runs, that's acceptable, but for claiming exclusive ownership of a file queue, it's insufficient. You need a stronger atomic operation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Overall Flow
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Architecture Diagram: From Race to Resolution
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Codex usage_limit_exceeded から復帰
              │
              ▼
    ~/dev/note-autolike/done/ に 57 本が滞留
    ┌────────────────────────────────────────┐
    │  job_20260917_001.json                │
    │  job_20260917_002.json                │
    │  ...                                  │
    │  job_20260917_057.json                │
    └────────────────────────────────────────┘
              │
    5 レーンが launchd により一斉起動
    ┌────────────────────────────────────────┐
    │ com.lily.codex-note-funnel   (10:40)  │
    │ com.lily.autolike.note1               │
    │ com.lily.autolike.note2               │
    │ com.lily.autolike.funnel-pm  ← 4連射  │
    │ com.lily.autolike.social              │
    └────────────────────────────────────────┘
              │
    【修正前】各レーンが done/ を glob → 先頭ファイルを取得
              │  排他なし → 全レーンが job_001 を同時に read
              ↓
    funnel-pm が 1分差で同一 JSON を 4 回処理
    ─────────────────────────────────────────
    【修正後】mv による原子的所有権取得
    ┌────────────────────────────────────────┐
    │  mv done/job_001.json processing/     │
    │  ├─ 成功: 自プロセスのみが処理を継続   │
    │  └─ 失敗: 他プロセスが取得済み → exit 0│
    └────────────────────────────────────────┘
              │
    「1ファイル = 1プロセスのみ処理」が保証される
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Why mv Is Atomic
&lt;/h3&gt;

&lt;p&gt;On the same filesystem, &lt;code&gt;mv&lt;/code&gt; executes a single POSIX &lt;code&gt;rename(2)&lt;/code&gt; system call. Because the "rename this file" operation completes indivisibly at the kernel level, even if two processes simultaneously run &lt;code&gt;mv done/job_001.json processing/job_001.json&lt;/code&gt;, &lt;strong&gt;exactly one succeeds and the other is guaranteed to fail.&lt;/strong&gt; No window opens between "check" and "acquire."&lt;/p&gt;

&lt;p&gt;This is the fundamental difference between a PID check and &lt;code&gt;mv&lt;/code&gt;. With a PID check, "check" and "start processing" are separate operations. With &lt;code&gt;mv&lt;/code&gt;, "check" and "acquire" are completed inside one system call.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 2プロセスが同時に実行した場合の挙動&lt;/span&gt;
&lt;span class="c"&gt;# プロセスA: mv done/job_001.json processing/job_001.json → 成功（終了コード 0）&lt;/span&gt;
&lt;span class="c"&gt;# プロセスB: mv done/job_001.json processing/job_001.json → 失敗（No such file or directory, 終了コード 1）&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Wiring It into the Pickup Script
&lt;/h3&gt;

&lt;p&gt;Here's the implementation to insert at the top of &lt;code&gt;run-codex-funnel.sh&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-uo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;DONE_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;~/dev/note-autolike/done
&lt;span class="nv"&gt;PROCESSING_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;~/dev/note-autolike/processing
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROCESSING_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# done/ から処理対象を1本選ぶ&lt;/span&gt;
&lt;span class="nv"&gt;TARGET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DONE_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;/&lt;span class="k"&gt;*&lt;/span&gt;.json 2&amp;gt;/dev/null | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TARGET&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;0          &lt;span class="c"&gt;# 対象なし → 正常終了&lt;/span&gt;

&lt;span class="nv"&gt;BASENAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;basename&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TARGET&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# アトミックに所有権を取得。失敗 = 他プロセスが取得済み&lt;/span&gt;
&lt;span class="nb"&gt;mv&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TARGET&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROCESSING_DIR&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$BASENAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;0

&lt;span class="nv"&gt;WORKING&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROCESSING_DIR&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$BASENAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="c"&gt;# ここから先は自プロセスだけが処理する権利を持つ&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There's effectively only one situation in which &lt;code&gt;mv&lt;/code&gt; fails: another process has already &lt;code&gt;mv&lt;/code&gt;'d the file and it no longer exists. That is precisely the race condition itself, so exiting immediately with &lt;code&gt;exit 0&lt;/code&gt; is the correct response. You don't even need to write an error log.&lt;/p&gt;

&lt;p&gt;A cross-device &lt;code&gt;mv&lt;/code&gt; (spanning different mount points) is not atomic, so be careful—but in this setup, both &lt;code&gt;done/&lt;/code&gt; and &lt;code&gt;processing/&lt;/code&gt; live under &lt;code&gt;~/dev/note-autolike/&lt;/code&gt;, which guarantees the same filesystem.&lt;/p&gt;

&lt;h3&gt;
  
  
  Connecting with com.lily.codex-note-funnel.plist
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;ProgramArguments&lt;/code&gt; in &lt;code&gt;com.lily.codex-note-funnel.plist&lt;/code&gt; are structured like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;ProgramArguments&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;array&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;~/.claude/scripts/claude-quota-guard.py&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;--job&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;com.lily.codex-note-funnel&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;--priority&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;--&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;~/dev/note-autolike/run-codex-funnel.sh&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/array&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;claude-quota-guard.py&lt;/code&gt; runs first as a quota-control wrapper, and if there's headroom, it launches &lt;code&gt;run-codex-funnel.sh&lt;/code&gt;. Since the pickup logic lives inside &lt;code&gt;run-codex-funnel.sh&lt;/code&gt;, inserting the &lt;code&gt;mv&lt;/code&gt; ownership claim into the first few lines of the script is all it takes. No plist changes required.&lt;/p&gt;

&lt;p&gt;launchd will keep calling &lt;code&gt;run-codex-funnel.sh&lt;/code&gt; at 10:40 and 16:40. Even with 57 JSON files backed up right after Codex recovers from &lt;code&gt;usage_limit_exceeded&lt;/code&gt;, every time &lt;code&gt;run-codex-funnel.sh&lt;/code&gt; starts it repeats the same behavior: "first claim one file with &lt;code&gt;mv&lt;/code&gt;; if that fails, exit immediately." Even if five processes, including the funnel-pm lane, run at the same time, exactly one process handles each file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation Details
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Full Script: The Order for Stacking Defensive Layers
&lt;/h3&gt;

&lt;p&gt;The first half showed only the single &lt;code&gt;mv&lt;/code&gt; line, but to actually run it in production you need layers before and after. Looking at &lt;code&gt;ProgramArguments&lt;/code&gt; in &lt;code&gt;com.lily.codex-note-funnel.plist&lt;/code&gt;, &lt;code&gt;claude-quota-guard.py&lt;/code&gt; runs as the leading wrapper, and then &lt;code&gt;run-codex-funnel.sh&lt;/code&gt; is called. The full script looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# launchd 環境の PATH は最小限のため先頭で上書きする&lt;/span&gt;
&lt;span class="nv"&gt;PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
&lt;span class="nb"&gt;export &lt;/span&gt;PATH

&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-uo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;DONE_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;~/dev/note-autolike/done
&lt;span class="nv"&gt;PROCESSING_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;~/dev/note-autolike/processing
&lt;span class="nv"&gt;PROCESSED_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;~/dev/note-autolike/processed
&lt;span class="nv"&gt;LOG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;~/dev/note-autolike/logs/codex-funnel.log

&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROCESSING_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROCESSED_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# 起動時リカバリ: 前回クラッシュで processing/ に残ったファイルを done/ へ戻す&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;stale &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROCESSING_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;/&lt;span class="k"&gt;*&lt;/span&gt;.json&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$stale&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;break
    &lt;/span&gt;&lt;span class="nv"&gt;age&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;stat&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; %m &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$stale&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="k"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$age&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-gt&lt;/span&gt; 3600 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
        &lt;/span&gt;&lt;span class="nb"&gt;mv&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$stale&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DONE_DIR&lt;/span&gt;&lt;span class="s2"&gt;/"&lt;/span&gt; 2&amp;gt;/dev/null
        &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"[&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="s1"&gt;'+%F %T'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;] recovered stale: &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;basename&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$stale&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;fi
done&lt;/span&gt;

&lt;span class="c"&gt;# done/ から処理対象を1本選ぶ&lt;/span&gt;
&lt;span class="nv"&gt;TARGET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DONE_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;/&lt;span class="k"&gt;*&lt;/span&gt;.json 2&amp;gt;/dev/null | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TARGET&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;0

&lt;span class="nv"&gt;BASENAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;basename&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TARGET&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# ─── ここが核心 ─────────────────────────────────────────&lt;/span&gt;
&lt;span class="c"&gt;# アトミックに所有権を取得。失敗 = 他プロセスが取得済み → 即退出&lt;/span&gt;
&lt;span class="nb"&gt;mv&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TARGET&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROCESSING_DIR&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$BASENAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;span class="c"&gt;# ────────────────────────────────────────────────────────&lt;/span&gt;

&lt;span class="nv"&gt;WORKING&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROCESSING_DIR&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$BASENAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# クラッシュ時に processing/ へファイルが残るのを防ぐ EXIT トラップ&lt;/span&gt;
&lt;span class="c"&gt;# 正常完了時は processed/ へ移動、異常時は done/ へ差し戻す&lt;/span&gt;
_cleanup&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nv"&gt;$code&lt;/span&gt; &lt;span class="nt"&gt;-eq&lt;/span&gt; 0 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
        &lt;/span&gt;&lt;span class="nb"&gt;mv&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$WORKING&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROCESSED_DIR&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$BASENAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null
    &lt;span class="k"&gt;else
        &lt;/span&gt;&lt;span class="nb"&gt;mv&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$WORKING&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DONE_DIR&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$BASENAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null
        &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"[&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="s1"&gt;'+%F %T'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;] ERROR exit=&lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="s2"&gt;, returned: &lt;/span&gt;&lt;span class="nv"&gt;$BASENAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;fi&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="nb"&gt;trap&lt;/span&gt; &lt;span class="s1"&gt;'_cleanup'&lt;/span&gt; EXIT

&lt;span class="c"&gt;# ── ここから先は自プロセスだけが $WORKING を処理する ──&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"[&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="s1"&gt;'+%F %T'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;] processing: &lt;/span&gt;&lt;span class="nv"&gt;$BASENAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="c"&gt;# ... 本体処理 ...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Why Override PATH at the Top
&lt;/h3&gt;

&lt;p&gt;Look at &lt;code&gt;EnvironmentVariables&lt;/code&gt; in &lt;code&gt;com.lily.codex-note-funnel.plist&lt;/code&gt;: the key exists, but the content is an empty &lt;code&gt;&amp;lt;dict/&amp;gt;&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;EnvironmentVariables&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;dict&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When launchd starts a process, its PATH is only the minimal set &lt;code&gt;/usr/bin:/bin:/usr/sbin:/sbin&lt;/code&gt;. If you &lt;code&gt;echo $PATH&lt;/code&gt; in a terminal you'll see &lt;code&gt;/usr/local/bin&lt;/code&gt; included, but that's added by &lt;code&gt;.zshrc&lt;/code&gt; or the nvm initialization script. launchd doesn't run any such shell initialization. Tools installed in &lt;code&gt;/usr/local/bin&lt;/code&gt; like &lt;code&gt;jq&lt;/code&gt;, &lt;code&gt;node&lt;/code&gt;, and &lt;code&gt;python3&lt;/code&gt; fail silently as "command not found" in scripts launched from a plist. &lt;strong&gt;The fix is one of two choices: write it in the plist's &lt;code&gt;EnvironmentVariables&lt;/code&gt;, or hardcode it at the top of the script.&lt;/strong&gt; The latter is more convenient because you can verify the script's behavior on its own, so I've standardized on &lt;code&gt;export PATH&lt;/code&gt; at the top.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why &lt;code&gt;set -uo pipefail&lt;/code&gt; and &lt;code&gt;|| exit 0&lt;/code&gt; Must Go Together
&lt;/h3&gt;

&lt;p&gt;When &lt;code&gt;set -uo pipefail&lt;/code&gt; is active, the whole script aborts the moment a command returns a non-zero exit code. &lt;code&gt;mv&lt;/code&gt; returns exit code 1 when the source file doesn't exist. That maps exactly onto the normal race scenario of "another process already claimed it."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# NG: set -uo pipefail 環境では mv 失敗でスクリプトが非ゼロ終了する&lt;/span&gt;
&lt;span class="nb"&gt;mv&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TARGET&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROCESSING_DIR&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$BASENAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null

&lt;span class="c"&gt;# OK: 失敗を明示的に「正常な退出」に変換する&lt;/span&gt;
&lt;span class="nb"&gt;mv&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TARGET&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROCESSING_DIR&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$BASENAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;2&amp;gt;/dev/null&lt;/code&gt; is needed as its counterpart. With &lt;code&gt;|| exit 0&lt;/code&gt; alone, "No such file or directory" is printed to &lt;code&gt;stderr&lt;/code&gt;. Since &lt;code&gt;StandardErrorPath&lt;/code&gt; in &lt;code&gt;com.lily.codex-note-funnel.plist&lt;/code&gt; points to &lt;code&gt;~/dev/note-autolike/logs/codex-funnel.error.log&lt;/code&gt;, every race piles a line that isn't actually an error into the error log. When you monitor logs, this becomes the cause of false alarms like "the error log is growing."&lt;/p&gt;

&lt;h3&gt;
  
  
  The "Double Check" Idiom Demonstrated by autolike-plist-reconcile.sh
&lt;/h3&gt;

&lt;p&gt;The PID-check portion of &lt;code&gt;autolike-plist-reconcile.sh&lt;/code&gt; has a subtlety that's easy to miss.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;pid&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;launchctl list | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nv"&gt;l&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$L&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s1"&gt;'$3==l{print $1}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$pid&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$pid&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"-"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first column returned by &lt;code&gt;launchctl list&lt;/code&gt; is either a PID or &lt;code&gt;-&lt;/code&gt;. &lt;code&gt;-&lt;/code&gt; means the job is registered but not currently running. With &lt;code&gt;[ -n "$pid" ]&lt;/code&gt; alone, a &lt;code&gt;pid&lt;/code&gt; of &lt;code&gt;-&lt;/code&gt; would also be judged as "has a PID." That's why &lt;code&gt;[ "$pid" != "-" ]&lt;/code&gt; is joined with AND.&lt;/p&gt;

&lt;p&gt;This idiom of checking "the variable is non-empty" and "the value is meaningful" separately can be applied to the &lt;code&gt;mv&lt;/code&gt; pattern too. If you want to add a defensive layer that verifies the &lt;code&gt;WORKING&lt;/code&gt; file actually exists rather than looking only at the exit code of &lt;code&gt;mv&lt;/code&gt;, write it like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mv&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TARGET&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROCESSING_DIR&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$BASENAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;span class="c"&gt;# 念のため: mv が成功したはずなのにファイルがない（同一FSでない等）&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$WORKING&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"FATAL: mv succeeded but file missing"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This extra check is normally never reached, but it makes the symptom explicit if you accidentally run a cross-device &lt;code&gt;mv&lt;/code&gt;. In an environment with &lt;code&gt;set -uo pipefail&lt;/code&gt; active, having the cause in the log beats dying silently—debugging afterward is dramatically faster.&lt;/p&gt;

&lt;h3&gt;
  
  
  Design Rationale for Startup Recovery
&lt;/h3&gt;

&lt;p&gt;The step at the top of the script that "returns &lt;code&gt;processing/&lt;/code&gt; files older than one hour to &lt;code&gt;done/&lt;/code&gt;" is a dead-letter countermeasure.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;age&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;stat&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; %m &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$stale&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="k"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$age&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-gt&lt;/span&gt; 3600 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;mv&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$stale&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DONE_DIR&lt;/span&gt;&lt;span class="s2"&gt;/"&lt;/span&gt; 2&amp;gt;/dev/null
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the process crashes after claiming ownership with &lt;code&gt;mv&lt;/code&gt;, the file stays in &lt;code&gt;processing/&lt;/code&gt;. The EXIT trap is designed to return it to &lt;code&gt;done/&lt;/code&gt;, but the trap won't run in cases like a forced kill with signal 9. The symptom of "I check &lt;code&gt;done/&lt;/code&gt; the next morning and it's empty, yet nothing got processed" is caused by this pattern. By running recovery at startup, the file automatically re-enters the queue on the next launchd invocation. The threshold is 3600 seconds (one hour) so that legitimately long-running jobs don't get returned by mistake. Since &lt;code&gt;StartCalendarInterval&lt;/code&gt; in &lt;code&gt;com.lily.codex-note-funnel.plist&lt;/code&gt; has the two entries 10:40 and 16:40, the maximum schedule gap is six hours. One hour is a sufficient safety margin within that range.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where I Got Stuck
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Stuck #1: The Race Was Gone, but the Error Log Kept Growing
&lt;/h3&gt;

&lt;p&gt;The day after implementing &lt;code&gt;mv || exit 0&lt;/code&gt;, I looked at &lt;code&gt;codex-funnel.error.log&lt;/code&gt; and the line count had gone from 1 to 32. The processed count was increasing normally. The 4x duplicates were gone. And yet the error log was growing.&lt;/p&gt;

&lt;p&gt;At first I assumed "there must be an exception somewhere" and started debugging the main processing. After about 30 minutes of chasing it, I realized &lt;code&gt;2&amp;gt;/dev/null&lt;/code&gt; was missing. The code I first wrote was this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mv&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TARGET&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROCESSING_DIR&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$BASENAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;||&lt;/code&gt; only handles the exit code. It doesn't stop output to &lt;code&gt;stderr&lt;/code&gt;. launchd keeps writing &lt;code&gt;stderr&lt;/code&gt; to &lt;code&gt;StandardErrorPath&lt;/code&gt;. As a result, "No such file or directory" accumulated at every moment of contention.&lt;/p&gt;

&lt;p&gt;The correct form is this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mv&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TARGET&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROCESSING_DIR&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$BASENAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cause was that I hadn't built the habit of always writing these two together. My preconception that "error log growing = bug in the main body" sent my debugging in the wrong direction. &lt;strong&gt;When the error log grows, the first thing to check is "am I silencing the normal failures?"—I should have made that order routine from the start.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Stuck #2: Files Kept Piling Up in processing/ While done/ Looked Empty Forever
&lt;/h3&gt;

&lt;p&gt;This happened in the version before I implemented the EXIT trap. Codex crashed overnight several times in a row, and when I checked the next morning, &lt;code&gt;done/&lt;/code&gt; was empty but processing wasn't happening.&lt;/p&gt;

&lt;p&gt;Tracing the symptom, five JSON files had accumulated in &lt;code&gt;processing/&lt;/code&gt;. Each time the script started, it checked &lt;code&gt;done/&lt;/code&gt;, found &lt;code&gt;TARGET&lt;/code&gt; empty, and did &lt;code&gt;exit 0&lt;/code&gt;. But the "files that should be processed" were stuck in &lt;code&gt;processing/&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;done/       ← 空（次の仕事はここに来る）
processing/ ← job_001.json, job_002.json ...  ← ゾンビ
processed/  ← 完了済み
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This state does double damage. First, processing stops. Second, "the fact that it's stuck isn't visible from outside." Because &lt;code&gt;done/&lt;/code&gt; is empty, the automation looks healthy. But even when new files arrive in &lt;code&gt;done/&lt;/code&gt;, nobody ever touches the zombies in &lt;code&gt;processing/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The fix combined two things: &lt;strong&gt;returning files via the EXIT trap&lt;/strong&gt; and &lt;strong&gt;recovering stale &lt;code&gt;processing/&lt;/code&gt; files at startup.&lt;/strong&gt; Either one alone is insufficient; to handle forced kills where the trap doesn't run, startup recovery is required. After implementing this, I only need to periodically &lt;code&gt;ls -la&lt;/code&gt; the &lt;code&gt;processing/&lt;/code&gt; directory to confirm nothing is lingering, and this type of stall resolves itself by the next morning.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stuck #3: Works When Run from the Terminal, Silently Does Nothing from launchd
&lt;/h3&gt;

&lt;p&gt;This was the failure that burned the most time. Running &lt;code&gt;run-codex-funnel.sh&lt;/code&gt; directly in the terminal processes JSON files normally. Via launchd, "processing: ..." never appears in &lt;code&gt;codex-funnel.log&lt;/code&gt;. The error log is empty too. The script is starting, but doing nothing.&lt;/p&gt;

&lt;p&gt;What I used to pin down the cause was reproducing the shell launchd starts as faithfully as possible.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;env&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nv"&gt;HOME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nv"&gt;PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/usr/bin:/bin:/usr/sbin:/sbin bash &lt;span class="nt"&gt;-l&lt;/span&gt; ~/dev/note-autolike/run-codex-funnel.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Running it with &lt;code&gt;env -i&lt;/code&gt; to minimize the environment variables, &lt;code&gt;jq: command not found&lt;/code&gt; appeared partway through. The main processing used &lt;code&gt;jq&lt;/code&gt; to read the &lt;code&gt;needs_imagegen_thumbnail&lt;/code&gt; flag from the JSON, but &lt;code&gt;jq&lt;/code&gt; was installed at &lt;code&gt;/usr/local/bin/jq&lt;/code&gt;. With &lt;code&gt;set -uo pipefail&lt;/code&gt; active, the script exits non-zero the moment &lt;code&gt;jq&lt;/code&gt; isn't found. The exit code should have been written to &lt;code&gt;codex-funnel.error.log&lt;/code&gt;, but since the error message itself never went to &lt;code&gt;stderr&lt;/code&gt; (no &lt;code&gt;jq&lt;/code&gt; means no error text either), the log was empty.&lt;/p&gt;

&lt;p&gt;Had I read that &lt;code&gt;EnvironmentVariables&lt;/code&gt; in &lt;code&gt;com.lily.codex-note-funnel.plist&lt;/code&gt; was empty, I would have noticed sooner. The cause was skipping verification on the assumption that "PATH is probably fine" without actually reading the file. Since then, &lt;strong&gt;writing &lt;code&gt;PATH=&lt;/code&gt; at the top&lt;/strong&gt; has been an absolute rule for every script run via launchd.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin
&lt;span class="nb"&gt;export &lt;/span&gt;PATH
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Whether this one line is present is now the first thing I check in code review.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stuck #4: &lt;code&gt;ls *.json&lt;/code&gt; Returned Exit Code 2 on "No Files" and Crashed the Script
&lt;/h3&gt;

&lt;p&gt;When &lt;code&gt;done/&lt;/code&gt; is empty, &lt;code&gt;ls "$DONE_DIR"/*.json&lt;/code&gt; fails shell glob expansion and returns exit code 2. In a &lt;code&gt;set -uo pipefail&lt;/code&gt; environment, this causes the script to terminate abnormally.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# NG: done/ が空のとき ls が exit 2 → pipefail でスクリプト全体が落ちる&lt;/span&gt;
&lt;span class="nv"&gt;TARGET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DONE_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;/&lt;span class="k"&gt;*&lt;/span&gt;.json | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# OK: 2&amp;gt;/dev/null でエラーを捨て、変数が空かどうかで判断する&lt;/span&gt;
&lt;span class="nv"&gt;TARGET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DONE_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;/&lt;span class="k"&gt;*&lt;/span&gt;.json 2&amp;gt;/dev/null | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TARGET&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This problem wouldn't occur if the design treated an empty &lt;code&gt;done/&lt;/code&gt; as "abnormal." But this setup is designed so that "in normal operation, &lt;code&gt;done/&lt;/code&gt; is empty more often than not," so empty is the normal path. &lt;strong&gt;To "treat empty as normal," you need a line that explicitly absorbs the command's failure.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Looking at how &lt;code&gt;autolike-plist-reconcile.sh&lt;/code&gt; is written, there are three places where it uses &lt;code&gt;continue&lt;/code&gt; to skip out of the loop.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;want&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nv"&gt;$PB&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"Print :EnvironmentVariables:AUTOLIKE_TIMEOUT_SEC"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$P&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;continue
&lt;/span&gt;&lt;span class="nv"&gt;have&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;launchctl print &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$D&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$L&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null | ...&lt;span class="si"&gt;)&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$have&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$want&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$have&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;|| continue&lt;/code&gt; appears throughout. The decision "if we can't get this lane's info, skip it" is written as moving on to the next iteration without stopping on the error. This idea of "converting a failure into an instruction for the next step" is the same philosophy as &lt;code&gt;mv ... || exit 0&lt;/code&gt;. Errors aren't something to leave in the log—&lt;strong&gt;they're a signal for the next action.&lt;/strong&gt; Once I switched to this viewpoint, my scripts became dramatically clearer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stuck #5: It Took a Full Day to Notice &lt;code&gt;funnel-pm&lt;/code&gt; Was Firing 4x
&lt;/h3&gt;

&lt;p&gt;The last "stuck" is less about technology and more about observability. I only noticed the 4x duplicate on September 17 when I counted the files in &lt;code&gt;processed/&lt;/code&gt; at 05:20 the next morning, on the 18th. Looking at the log, there were four entries for the same JSON, one minute apart.&lt;/p&gt;

&lt;p&gt;For the 17 hours the problem was happening, I knew nothing. The logs accumulate in &lt;code&gt;~/dev/note-autolike/logs/codex-funnel.log&lt;/code&gt;, but nobody monitors them in real time. The fact that "processing was duplicated" could only be detected from duplicate filenames in &lt;code&gt;processed/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I now use this &lt;strong&gt;duplicate-detection one-liner&lt;/strong&gt; in my morning check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; ~/dev/note-autolike/processed/ | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s1"&gt;'s/_[0-9]*$//'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;uniq&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It strips the timestamp portion of the filenames and checks for duplicate base names. If it returns anything, that day's processing had a double execution.&lt;/p&gt;

&lt;p&gt;Structural improvement that drives duplicates to zero (the &lt;code&gt;mv&lt;/code&gt; pattern) and, independently, monitoring that lets you notice duplicates happened—these are problems on different layers. Build a design that doesn't break, and also have a mechanism that lets you notice a break the same day rather than the next. In an environment where automation feeds real revenue, this two-tier approach is indispensable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfalls
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Environment and Configuration
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;launchd does not inherit PATH. Running with an empty &lt;code&gt;EnvironmentVariables&lt;/code&gt; block will get you stuck.&lt;/strong&gt; &lt;code&gt;EnvironmentVariables&lt;/code&gt; in &lt;code&gt;com.lily.codex-note-funnel.plist&lt;/code&gt; is empty, and the effective PATH at launchd startup is only &lt;code&gt;/usr/bin:/bin:/usr/sbin:/sbin&lt;/code&gt;. The codex installed via nvm is not on this PATH. Even though &lt;code&gt;run-codex-funnel.sh&lt;/code&gt; has &lt;code&gt;set -uo pipefail&lt;/code&gt;, in a "command not found" situation there are cases where &lt;code&gt;-u&lt;/code&gt; reacts late (&lt;code&gt;which codex&lt;/code&gt; returns an empty string). The fastest diagnosis is to start it manually with &lt;code&gt;launchctl start com.lily.codex-note-funnel&lt;/code&gt; and check the logs.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;Nice: 10&lt;/code&gt; and &lt;code&gt;LowPriorityIO: true&lt;/code&gt; conflict with burst processing.&lt;/strong&gt; Both plist settings deprioritize the script's execution when the system is under heavy load. When you want to churn through 57 JSON files right after Codex recovers, &lt;code&gt;Nice: 10&lt;/code&gt; is a "yield CPU to other user operations" setting, so processing is delayed. "Run quietly in the background" and "process at full speed after recovery" are in conflict. Decide which to prioritize and write the intent in a comment. The current plist chooses the former.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The default &lt;code&gt;ThrottleInterval&lt;/code&gt; depends on the launchd version. Accumulated non-zero exits can cause &lt;code&gt;StartCalendarInterval&lt;/code&gt; scheduled starts to be skipped.&lt;/strong&gt; This ties directly into failure #3 in part 2 (throttling on &lt;code&gt;exit 1&lt;/code&gt;), but to add: if 10:40 is skipped due to throttling, the next scheduled run is 16:40, six hours later. The reliable diagnosis is to go back through days where nothing was written to &lt;code&gt;codex-funnel.error.log&lt;/code&gt; (&lt;code&gt;StandardErrorPath&lt;/code&gt;). If you consistently use &lt;code&gt;exit 0&lt;/code&gt;, the default &lt;code&gt;ThrottleInterval&lt;/code&gt; setting is fine, but if you're "leaving it to the default," record that intent in a plist comment or documentation.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;An &lt;code&gt;mv&lt;/code&gt; failure due to a missing &lt;code&gt;claimed/&lt;/code&gt; directory and an &lt;code&gt;mv&lt;/code&gt; failure due to race avoidance both return the same &lt;code&gt;exit 0&lt;/code&gt;.&lt;/strong&gt; I touched on this in failure #5 of part 2, but let me restate it as the underlying design trade-off. Giving "normal race avoidance" and "abnormal environment misconfiguration" the same exit code prevents launchd's throttle, but delays discovery of problems. Since you're paying the price of this silence, external monitoring is mandatory.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Traps Specific to the mv Claim Pattern
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Cross-device &lt;code&gt;mv&lt;/code&gt; is not atomic.&lt;/strong&gt; &lt;code&gt;POSIX rename(2)&lt;/code&gt; is serialized by the kernel only "on the same filesystem." If &lt;code&gt;queue/&lt;/code&gt; and &lt;code&gt;claimed/&lt;/code&gt; span different mount points (e.g., NFS, Docker volumes, external drives), the operation decomposes internally into copy + delete, and a TOCTOU window opens. This setup has both directories under &lt;code&gt;~/dev/note-autolike/&lt;/code&gt;, so there's no issue, but whenever you change the directory layout, verify the same filesystem with &lt;code&gt;df -h&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Filename sort order becomes unstable when files are created in the same second.&lt;/strong&gt; The lexicographic sort in &lt;code&gt;ls -1 "$QUEUE_DIR"/*.json | sort | head -1&lt;/code&gt; assumes the timestamp portions of the filenames differ. When batch submission creates multiple files within one second, same-name prefixes collide, and five lanes grab different files as the "first." As a result, no claim contention occurs, all lanes process simultaneously, and five Codex sessions start at once. The workaround is to include epoch seconds + PID suffix in the filename: &lt;code&gt;job_$(date +%s)_$$_$(uuidgen | cut -d- -f1).json&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Leftover files in &lt;code&gt;claimed/&lt;/code&gt; become a silent dead-letter queue.&lt;/strong&gt; If processing crashes midway, the file stays in &lt;code&gt;claimed/&lt;/code&gt;. The next launchd start looks at &lt;code&gt;queue/&lt;/code&gt;, so files in &lt;code&gt;claimed/&lt;/code&gt; are never picked up again. In fact, after Codex recovered on September 17, 2026, there was a day when several files were stuck in &lt;code&gt;claimed/&lt;/code&gt;. Since adding monitoring that alerts when a file in &lt;code&gt;claimed/&lt;/code&gt; has gone more than an hour without an update, the lag before noticing this state has dropped to zero.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The &lt;code&gt;claude-quota-guard.py&lt;/code&gt; fd-inheritance issue remains even after the &lt;code&gt;mv&lt;/code&gt; claim.&lt;/strong&gt; The first entry in &lt;code&gt;ProgramArguments&lt;/code&gt; in &lt;code&gt;com.lily.codex-note-funnel.plist&lt;/code&gt; is &lt;code&gt;~/.claude/scripts/claude-quota-guard.py&lt;/code&gt;. While this Python wrapper launches &lt;code&gt;run-codex-funnel.sh&lt;/code&gt; via &lt;code&gt;subprocess.Popen&lt;/code&gt;, the job appears "running" to launchd. The PID check in &lt;code&gt;autolike-plist-reconcile.sh&lt;/code&gt; (verifying via &lt;code&gt;launchctl list | awk -v l="$L" '$3==l{print $1}'&lt;/code&gt; that the PID is not &lt;code&gt;-&lt;/code&gt;), which skips reload on the judgment "codex-funnel is running," correctly captures this "wrapper is alive" state as well. The &lt;code&gt;mv&lt;/code&gt; claim eliminated the race, but you need to stay aware of how PIDs are read given that the wrapper launch is a precondition.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The glob in &lt;code&gt;reconcile.sh&lt;/code&gt; excludes &lt;code&gt;codex-note-funnel&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;autolike-plist-reconcile.sh&lt;/code&gt; targets &lt;code&gt;com.lily.autolike.*.plist&lt;/code&gt;. &lt;code&gt;com.lily.codex-note-funnel.plist&lt;/code&gt; is a separate file and doesn't match this glob. Since the automatic timeout-value reload that reconcile performs doesn't apply to codex-funnel, changing codex-funnel's timeout setting requires a manual &lt;code&gt;launchctl bootout&lt;/code&gt; + &lt;code&gt;bootstrap&lt;/code&gt;. If this is intentional, state it explicitly in a comment so that six months from now you won't be confused.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Operations and Monitoring Traps
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;No log rotation configured for &lt;code&gt;codex-funnel.log&lt;/code&gt;.&lt;/strong&gt; The plist's &lt;code&gt;StandardOutPath&lt;/code&gt; points to &lt;code&gt;~/dev/note-autolike/logs/codex-funnel.log&lt;/code&gt;. launchd doesn't rotate logs. With dozens of lines per run accumulating continuously, it reaches tens of MB in a year. You need explicit rotation via &lt;code&gt;newsyslog&lt;/code&gt; or &lt;code&gt;logrotate&lt;/code&gt;, or line-count cap management inside the script.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Without a mechanism to automatically detect "log not updating," you won't notice a stall for a week.&lt;/strong&gt; I've separately added monitoring that fires an alert if the update timestamp of &lt;code&gt;claimed/&lt;/code&gt; hasn't changed within 15 minutes after the 10:40 start. The &lt;code&gt;exit 0&lt;/code&gt; design maximizes compatibility with launchd, but in exchange carries the silent risk that "normal exit and silent failure are indistinguishable." This monitoring script isn't optional—it should be installed as a pair with the mv claim pattern.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Best Practices
&lt;/h2&gt;

&lt;p&gt;Here are the rules that solidified after six months of running an autonomous environment at the ¥1.2M/month scale, each grounded in real code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Put the &lt;code&gt;mv&lt;/code&gt; ownership claim at the very top of the script&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Run &lt;code&gt;mv&lt;/code&gt; before argument validation, log initialization, or directory checks. If you can't get the file, there's no need to run any subsequent processing at all. This minimizes CPU and memory usage per lane while resolving contention as fast as possible.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;TARGET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$QUEUE_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;/&lt;span class="k"&gt;*&lt;/span&gt;.json 2&amp;gt;/dev/null | &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TARGET&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;span class="nv"&gt;BASENAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;basename&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TARGET&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;mv&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;QUEUE_DIR&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BASENAME&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CLAIMED_DIR&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BASENAME&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;span class="c"&gt;# ここより下は自プロセスだけが実行する&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Log successful claims; keep failures (race avoidance) silent&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;With "success = logged, race avoidance = silent," &lt;code&gt;grep "claimed:" codex-funnel.log | wc -l&lt;/code&gt; becomes today's processed count. A count log that needs no filtering dramatically reduces the implementation cost of periodic monitoring scripts. Pairing &lt;code&gt;2&amp;gt;/dev/null&lt;/code&gt; with &lt;code&gt;exit 0&lt;/code&gt; is a consistent pattern.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Return &lt;code&gt;exit 0&lt;/code&gt; only for "normal race avoidance" and "empty queue"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For "directory doesn't exist," "permission error," and "unexpected mv error," return &lt;code&gt;exit 1&lt;/code&gt;. The more silent exits you have, the later you discover failures. Set up the environment with an initial deploy script, then write the script body on the premise that "only normal exits are &lt;code&gt;exit 0&lt;/code&gt;," and maintenance cost goes down.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Make state visible with the three directories &lt;code&gt;queue/&lt;/code&gt;, &lt;code&gt;claimed/&lt;/code&gt;, &lt;code&gt;done/&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When the directory itself represents state, &lt;code&gt;ls ~/dev/note-autolike/claimed/ | wc -l&lt;/code&gt; tells you "the number of stuck files" at a glance. No flag management or DB needed—&lt;code&gt;ls&lt;/code&gt; alone is your monitoring tool.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Put epoch seconds + PID in filenames to stabilize sort order&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;FILENAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"job_&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;_&lt;/span&gt;&lt;span class="nv"&gt;$$&lt;/span&gt;&lt;span class="s2"&gt;_&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;uuidgen | &lt;span class="nb"&gt;cut&lt;/span&gt; &lt;span class="nt"&gt;-d-&lt;/span&gt; &lt;span class="nt"&gt;-f1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;.json"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;$$&lt;/code&gt; is the PID of the generating process. Even when multiple files are created in the same second, differing PIDs make the lexicographic order deterministic. This stabilizes the premise of the mv claim pattern: all five lanes select the same file as first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Standardize the "record the reason for skipping" pattern from &lt;code&gt;reconcile.sh&lt;/code&gt; across all scripts&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Lines 18–22 of &lt;code&gt;autolike-plist-reconcile.sh&lt;/code&gt; log the reason and &lt;code&gt;continue&lt;/code&gt; when a running job is found:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;pid&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;launchctl list | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nv"&gt;l&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$L&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s1"&gt;'$3==l{print $1}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$pid&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$pid&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"-"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"[&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="s1"&gt;'+%F %T'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;] &lt;/span&gt;&lt;span class="nv"&gt;$L&lt;/span&gt;&lt;span class="s2"&gt; 実行中(pid=&lt;/span&gt;&lt;span class="nv"&gt;$pid&lt;/span&gt;&lt;span class="s2"&gt;) のため見送り (&lt;/span&gt;&lt;span class="nv"&gt;$have&lt;/span&gt;&lt;span class="s2"&gt; -&amp;gt; &lt;/span&gt;&lt;span class="nv"&gt;$want&lt;/span&gt;&lt;span class="s2"&gt;)"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;continue
fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pattern of logging "why nothing was done" makes later failure tracing dramatically easier. Keep this habit even after the mv claim eliminates the race.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;7. Separate post-crash recovery from the main script&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you build the "return leftover &lt;code&gt;claimed/&lt;/code&gt; files to &lt;code&gt;queue/&lt;/code&gt;" step into the main script, a new race reappears where two processes simultaneously "return the leftover file → try to re-claim it." Stick to a design where recovery is delegated to an independent monitoring script that runs at low frequency.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;8. Record the start time in a sidecar file right after claiming&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mv&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;QUEUE_DIR&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BASENAME&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CLAIMED_DIR&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BASENAME&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;claimed_at&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; +%Y-%m-%dT%H:%M:%SZ&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;}"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CLAIMED_DIR&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;BASENAME&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.claim"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Writing the start time to a &lt;code&gt;.claim&lt;/code&gt; sidecar lets you write a recovery script that "returns files whose &lt;code&gt;.claim&lt;/code&gt; is older than one hour to &lt;code&gt;queue/&lt;/code&gt;." Aging detection for leftover files becomes precise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;9. Make the initial deploy script idempotent and re-runnable&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/dev/note-autolike/&lt;span class="o"&gt;{&lt;/span&gt;queue,claimed,done,logs&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Prepare a script that runs this before calling launchd's &lt;code&gt;bootstrap&lt;/code&gt;. This structurally prevents a recurrence of failure #5 from part 2, where &lt;code&gt;mv&lt;/code&gt; fails silently because a directory doesn't exist.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;10. Separate log files between lanes to prevent interleaved lines&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The plist settings &lt;code&gt;StandardOutPath: codex-funnel.log&lt;/code&gt; and &lt;code&gt;StandardErrorPath: codex-funnel.error.log&lt;/code&gt; point to separate files per lane, and that's correct. In the post-recovery situation where five lanes start at once, if a shared log gets interleaved, reconstructing what happened becomes impossible. Including the label name (&lt;code&gt;com.lily.codex-note-funnel&lt;/code&gt;) in the log filename is the minimum bar.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;11. Be aware of &lt;code&gt;ThrottleInterval&lt;/code&gt; behavior and make the setting's intent explicit&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you consistently use &lt;code&gt;exit 0&lt;/code&gt;, launchd's default &lt;code&gt;ThrottleInterval&lt;/code&gt; causes no problems. But consciously choose to "leave it at the default" and write the reason in the plist or operations docs. This prevents the confusion of "I didn't change any settings, so why?" when trouble hits six months later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;12. Verify the same filesystem with &lt;code&gt;df -h&lt;/code&gt; before placing &lt;code&gt;queue/&lt;/code&gt; and &lt;code&gt;claimed/&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The biggest risk to the atomicity premise is a cross-device &lt;code&gt;mv&lt;/code&gt;. Build the habit of checking whenever you change directories into the deploy script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;queue_dev&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;-P&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$QUEUE_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'NR==2{print $1}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;claimed_dev&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;-P&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CLAIMED_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'NR==2{print $1}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$queue_dev&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$claimed_dev&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"ERROR: cross-device mv"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;The problem that occurred on September 17, 2026, after Codex recovered from &lt;code&gt;usage_limit_exceeded&lt;/code&gt;—"the funnel-pm lane processed the same JSON 4 times, one minute apart"—was an inevitability of the design. When five lanes fire at once with 57 JSON files backed up, a pickup script without mutual exclusion collides structurally, not probabilistically. Because launchd is a scheduler, not a mutex.&lt;/p&gt;

&lt;p&gt;The fix was one line.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mv&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TARGET&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CLAIMED_DIR&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$BASENAME&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;POSIX &lt;code&gt;rename(2)&lt;/code&gt; is serialized by the kernel on the same filesystem. Fundamentally unlike a "check, then acquire" PID check, the check and the acquisition complete within a single system call. Unlike &lt;code&gt;flock&lt;/code&gt; with its fd-inheritance problem, the lock is decoupled from the life and death of the process. Since the file itself becomes the claim token, a leftover file after a crash remains in a state that's human-readable.&lt;/p&gt;

&lt;p&gt;The "do nothing if running" PID-check pattern in &lt;code&gt;autolike-plist-reconcile.sh&lt;/code&gt; and the mv claim pattern here were born from the same philosophy. The only difference is "whether there's a TOCTOU window." Both are designs that make the choice "exit if someone else is processing" in the very first operation.&lt;/p&gt;

&lt;p&gt;To keep an autonomous environment stable over the long term, the only way is to keep stacking up these kinds of "structures that don't permit contention in the first place." Procedural fixes shrink the wound; environmental fixes eliminate the event. The reason I could go from ¥0 to ¥1.2M a month in six months is that on the days things broke, I repeatedly chose "fix the structure" over "next time I'll do it differently."&lt;/p&gt;




&lt;p&gt;The full picture of the system, the breakdown of the ¥1.2M/month, and the 30-day setup guide are compiled in a paid note (Japanese).&lt;br&gt;
📕 &lt;a href="https://note.com/bokuwalily/n/n849b3a07784a" rel="noopener noreferrer"&gt;How to actually earn with a Claude Code autonomous environment — the system, real examples, getting started, and support&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by **Lily&lt;/em&gt;* — I ship iOS apps and automate my content stack with Claude Code.&lt;br&gt;
Follow along: &lt;a href="https://bokuwalily.com" rel="noopener noreferrer"&gt;Portfolio&lt;/a&gt; · &lt;a href="https://x.com/bokuwalily" rel="noopener noreferrer"&gt;X&lt;/a&gt; · &lt;a href="https://github.com/bokuwalily" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;*&lt;/p&gt;

</description>
      <category>automation</category>
      <category>launchd</category>
      <category>bash</category>
      <category>claudecode</category>
    </item>
    <item>
      <title>2 Pitfalls in Priority Probes: Letting One Real Request Through an Open Circuit Breaker</title>
      <dc:creator>Lily</dc:creator>
      <pubDate>Sun, 20 Sep 2026 00:00:06 +0000</pubDate>
      <link>https://dev.to/bokuwalily/2-pitfalls-in-priority-probes-letting-one-real-request-through-an-open-circuit-breaker-4km5</link>
      <guid>https://dev.to/bokuwalily/2-pitfalls-in-priority-probes-letting-one-real-request-through-an-open-circuit-breaker-4km5</guid>
      <description>&lt;p&gt;A circuit breaker that stops everything is easy to reason about — until the one job that can't afford to wait gets stopped along with everything else. In my setup, that job was posting: it fell to a 43% execution rate (231 runs vs. 308 skips) because engagement jobs had burned through the quota first. This post is about the fix inside &lt;code&gt;claude-quota-guard.py&lt;/code&gt; — &lt;code&gt;claim_priority_probe&lt;/code&gt; and &lt;code&gt;run_job&lt;/code&gt;, which let a single real request through while the circuit is still open — and the two pitfalls I hit along the way, one of which silently dropped a daily job for four days (2026-09-13 to 16).&lt;/p&gt;

&lt;p&gt;Last time, I wrote about &lt;a href="https://dev.to/bokuwalily/13-days-of-silence-how-one-bad-draft-froze-an-entire-dm-lane-52jd"&gt;how the gate blocks one row, not the whole batch&lt;/a&gt;. Before I get to &lt;code&gt;quota-catchup.py&lt;/code&gt; — the script that re-runs everything once it detects quota recovery — I want to look at &lt;strong&gt;how the SKIPPED markers it reads are actually produced&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: the circuit stops every job equally, but posting can't wait
&lt;/h2&gt;

&lt;p&gt;When &lt;code&gt;claude-quota-guard.py&lt;/code&gt; detects the quota limit, it returns &lt;code&gt;EXIT_CIRCUIT_OPEN&lt;/code&gt; (75) until &lt;code&gt;open_until&lt;/code&gt; and stops all 15 guarded jobs uniformly. A comment in the code explains why that wasn't enough:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# 🔴 2026-08-21: circuit が開くと全ジョブが一律で止まるため、消費の大半を占める
# 返信/エンゲージ系がクォータを使い切った巻き添えで「投稿」まで停止していた。
# 実測(launchd.log 累計): xpilot.autopost は 231実行/308スキップ＝実行率43%で、
# threadspilot.engage(64%) より優先度が低い扱いになっていた。投稿はその時間帯を逃すと
# 二度と埋まらないので、--priority を付けたジョブだけは circuit が開いていても
# この間隔で1回だけ試行を許す。試行が通ればクォータ回復の早期検知にもなる
# (従来は open_until まで盲目的に待つだけだった)。
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;code&gt;claude-quota-guard.py:18-24&lt;/code&gt;)&lt;/p&gt;

&lt;p&gt;The measured result: stopping everything uniformly meant the posting job (&lt;code&gt;xpilot.autopost&lt;/code&gt;) got caught in the crossfire of the quota-hungry engagement jobs and dropped to a 43% execution rate — effectively lower priority than &lt;code&gt;threadspilot.engage&lt;/code&gt; (64%). On top of that, &lt;code&gt;open_until&lt;/code&gt; is determined either by "the reset time parsed from the limit message" or by "a 6-hour cooldown" (&lt;code&gt;record_claude_result&lt;/code&gt;), so even if the actual quota comes back earlier, the circuit dutifully stays open until that time. For a job like posting, where "if you miss the time slot, it never gets filled," that's not something you can ignore.&lt;/p&gt;

&lt;h2&gt;
  
  
  The design: give &lt;code&gt;--priority&lt;/code&gt; jobs one attempt every 30 minutes
&lt;/h2&gt;

&lt;p&gt;The fix is: "even while the circuit is open, let priority jobs — and only priority jobs — send one real request at a fixed interval." The caller passes &lt;code&gt;--priority&lt;/code&gt; when handing a command to &lt;code&gt;run_job&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run_job&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;claude quota guard: --job requires a command after --&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;circuit_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;probe_claimed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;is_open&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;priority&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;claim_priority_probe&lt;/span&gt;&lt;span class="p"&gt;()):&lt;/span&gt;
            &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CLAUDE_QUOTA_JOB_SKIPPED &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;job=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; reason=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;reason&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; remaining=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;remaining_seconds&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;s ts=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
        &lt;span class="n"&gt;probe_claimed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CLAUDE_QUOTA_JOB_PRIORITY_PROBE &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;job=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; reason=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;reason&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; remaining=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;remaining_seconds&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;s ts=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;code&gt;claude-quota-guard.py:471-490&lt;/code&gt;)&lt;/p&gt;

&lt;p&gt;Even with &lt;code&gt;priority&lt;/code&gt; set, whether the job can actually attempt anything is decided by &lt;code&gt;claim_priority_probe()&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;claim_priority_probe&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;circuit が開いている間、優先ジョブに試行権を1つ渡す。

    間隔は全優先ジョブで共有する(=1本が使ったら次の枠まで他も待つ)。上限に本当に
    達している間に何本も叩いてもクォータは戻らないため、叩く回数自体を絞る。
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;PRIORITY_PROBE_INTERVAL&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;locked_state&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;last&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;last_priority_probe&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;last&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;PRIORITY_PROBE_INTERVAL&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
        &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;last_priority_probe&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;code&gt;claude-quota-guard.py:454-468&lt;/code&gt;; &lt;code&gt;PRIORITY_PROBE_INTERVAL&lt;/code&gt; defaults to 1800 seconds at &lt;code&gt;claude-quota-guard.py:25&lt;/code&gt;)&lt;/p&gt;

&lt;p&gt;&lt;code&gt;last_priority_probe&lt;/code&gt; is a single timestamp stored in &lt;code&gt;locked_state()&lt;/code&gt; (a JSON-persisted state guarded by &lt;code&gt;fcntl.flock&lt;/code&gt;). The key point is that this value lives &lt;strong&gt;per circuit, not per job&lt;/strong&gt;: it doesn't care &lt;em&gt;who&lt;/em&gt; probed, only "has it been 30 minutes since &lt;em&gt;anyone&lt;/em&gt; last probed?"&lt;/p&gt;

&lt;p&gt;The side that wins the attempt passes &lt;code&gt;CLAUDE_QUOTA_PRIORITY_PROBE=1&lt;/code&gt; in the environment of the child process it launches via &lt;code&gt;subprocess.run&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;    &lt;span class="n"&gt;guard&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;__file__&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;env&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CLAUDE_AUTOMATION_GUARD&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CLAUDE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;guard&lt;/span&gt;
    &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CLAUDE_BIN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;guard&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;probe_claimed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# 内側の run_claude に「プローブとして走っている」ことを伝える(これが無いと circuit で即 75 になる)
&lt;/span&gt;        &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CLAUDE_QUOTA_PRIORITY_PROBE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;code&gt;claude-quota-guard.py:491-498&lt;/code&gt;)&lt;/p&gt;

&lt;p&gt;The receiver of this flag is &lt;code&gt;run_claude&lt;/code&gt;. When the job command internally invokes the real &lt;code&gt;claude&lt;/code&gt; binary, PATH has been rewired so the call goes through the guard itself — which means a second &lt;code&gt;circuit_status()&lt;/code&gt; check runs inside the child process.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run_claude&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;arguments&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;circuit_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;is_open&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="c1"&gt;# run_job が優先プローブを claim した子プロセスだけは circuit を素通りして実 claude を叩く。
&lt;/span&gt;        &lt;span class="c1"&gt;# 結果は record_claude_result に入るので、上限文なら circuit が延び、成功なら閉じる。
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CLAUDE_QUOTA_PRIORITY_PROBE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CLAUDE_QUOTA_CIRCUIT_OPEN &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reason=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;reason&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; remaining=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;remaining_seconds&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;EXIT_CIRCUIT_OPEN&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CLAUDE_QUOTA_PRIORITY_PROBE_PASS &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reason=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;reason&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; remaining=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;remaining_seconds&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;code&gt;claude-quota-guard.py:416-432&lt;/code&gt;)&lt;/p&gt;

&lt;p&gt;If you forget to propagate the environment variable, &lt;code&gt;run_job&lt;/code&gt; wins the attempt, but the inner &lt;code&gt;run_claude&lt;/code&gt; checks the circuit again and immediately returns &lt;code&gt;EXIT_CIRCUIT_OPEN&lt;/code&gt;. The one line that gets you through both layers of the gate is &lt;code&gt;env["CLAUDE_QUOTA_PRIORITY_PROBE"] = "1"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;And the result of the actual call flows into &lt;code&gt;record_claude_result&lt;/code&gt; as usual. If the limit message still comes back, &lt;code&gt;open_until&lt;/code&gt; is extended; if the call succeeds, the circuit closes. &lt;strong&gt;Whether the probe fails or succeeds, that single result directly determines the circuit's next state&lt;/strong&gt; — which is what makes this "one real request let through."&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 1: "one every 30 minutes" is a counter shared by all priority jobs
&lt;/h2&gt;

&lt;p&gt;As the docstring on &lt;code&gt;claim_priority_probe&lt;/code&gt; says, &lt;code&gt;PRIORITY_PROBE_INTERVAL&lt;/code&gt; is &lt;strong&gt;shared across all jobs, not tracked per job&lt;/strong&gt;. Even if several &lt;code&gt;--priority&lt;/code&gt; jobs are scheduled inside the same 30-minute window, the moment the first one passes &lt;code&gt;claim_priority_probe()&lt;/code&gt;, &lt;code&gt;last_priority_probe&lt;/code&gt; is updated. Every subsequent job evaluates to False at &lt;code&gt;priority and claim_priority_probe()&lt;/code&gt; and falls through to SKIPPED &lt;strong&gt;without ever touching the real client&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This is intentional. While the limit is genuinely in effect, hammering it with multiple requests won't bring the quota back, so the design throttles the number of attempts themselves. Operationally, though, if you forget that "having multiple priority jobs does not mean each gets its own 30-minute opportunity," you will lose time wondering "why does this one job never get a turn to verify recovery?"&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfall 2: when the probe fails, it silently lands as &lt;code&gt;RAN exit≠0&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;This is the main subject of this post. After &lt;code&gt;subprocess.run&lt;/code&gt;, &lt;code&gt;run_job&lt;/code&gt; checks whether the probe came up empty and &lt;strong&gt;emits a different marker accordingly&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;env&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;check&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;OSError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;claude quota guard job=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;exc&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;127&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;probe_claimed&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;returncode&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;circuit_status&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;is_open&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="c1"&gt;# 優先プローブが上限のまま空振りした。RAN exit≠0 のまま残すと quota-catchup.py
&lt;/span&gt;        &lt;span class="c1"&gt;# （最新マーカー=SKIPPED だけを再実行）から漏れ、復帰後も当日分が欠番になる
&lt;/span&gt;        &lt;span class="c1"&gt;# （2026-09-13〜16 の note2-daily / codex-note-funnel 実測）。SKIPPED として記録する。
&lt;/span&gt;        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CLAUDE_QUOTA_JOB_SKIPPED &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;job=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; reason=priority-probe-quota exit=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;returncode&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; ts=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;returncode&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CLAUDE_QUOTA_JOB_RAN job=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; exit=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;returncode&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; ts=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;returncode&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;code&gt;claude-quota-guard.py:499-518&lt;/code&gt;)&lt;/p&gt;

&lt;p&gt;Before this branch existed, the code only looked at the fact that the probe had won the attempt and actually launched a child process, and fell straight through to the trailing &lt;code&gt;CLAUDE_QUOTA_JOB_RAN&lt;/code&gt;. Even when the probe hit the limit again and ended with &lt;code&gt;exit≠0&lt;/code&gt;, what remained in the log was &lt;code&gt;CLAUDE_QUOTA_JOB_RAN job=... exit=1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The problem is that this &lt;code&gt;RAN&lt;/code&gt; marker means "already done" as far as &lt;code&gt;quota-catchup.py&lt;/code&gt; is concerned. Here is &lt;code&gt;latest_job_marker&lt;/code&gt;, which &lt;code&gt;quota-catchup.py&lt;/code&gt; uses to narrow down re-run candidates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;latest_job_marker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;paths&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Optional&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;]]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Return the newest timestamped skip/run marker for one launchd label.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;marker_pattern&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CLAUDE_QUOTA_JOB_(SKIPPED|RAN)\s+job=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;escape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(?=\s|$).*\bts=(\d+)(?=\s|$)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;latest_marker_is_today_skip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;paths&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;today&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;marker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;latest_job_marker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;paths&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;marker&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="n"&gt;marker_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timestamp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;marker&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;marker_type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SKIPPED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromtimestamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;astimezone&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;date&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;today&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;code&gt;quota-catchup.py:160-193&lt;/code&gt;)&lt;/p&gt;

&lt;p&gt;As you can see, this check &lt;strong&gt;only looks at the marker type (&lt;code&gt;SKIPPED&lt;/code&gt; or &lt;code&gt;RAN&lt;/code&gt;) and never inspects the &lt;code&gt;exit&lt;/code&gt; code on the &lt;code&gt;RAN&lt;/code&gt; side&lt;/strong&gt;. Even if the probe failed with &lt;code&gt;exit=1&lt;/code&gt;, as long as the last log line is &lt;code&gt;CLAUDE_QUOTA_JOB_RAN&lt;/code&gt;, &lt;code&gt;latest_marker_is_today_skip&lt;/code&gt; returns False and the job quietly drops out of &lt;code&gt;find_candidates&lt;/code&gt;' re-run set.&lt;/p&gt;

&lt;p&gt;The consequence: even after the circuit really closes, the job sits there with the wrong record — "already &lt;code&gt;RAN&lt;/code&gt; today" — until its next scheduled time (the following morning, for instance). The comment records the real-world impact: &lt;code&gt;note2-daily&lt;/code&gt; and &lt;code&gt;codex-note-funnel&lt;/code&gt; both lost their daily run through this path between 2026-09-13 and 16.&lt;/p&gt;

&lt;p&gt;The fix is simple. Check the condition "the probe came up empty while the circuit was open" (&lt;code&gt;probe_claimed and result.returncode != 0 and circuit_status()["is_open"]&lt;/code&gt;) first, and only in that case emit &lt;code&gt;CLAUDE_QUOTA_JOB_SKIPPED reason=priority-probe-quota&lt;/code&gt; instead of &lt;code&gt;CLAUDE_QUOTA_JOB_RAN&lt;/code&gt;. The function's return value stays &lt;code&gt;result.returncode&lt;/code&gt;, unchanged. launchd's &lt;code&gt;LastExitStatus&lt;/code&gt; still records the actual failure correctly, while only the log marker that &lt;code&gt;quota-catchup.py&lt;/code&gt; reads gets relabeled as "still needs a retry." That's the separation of concerns.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; The exit code and "should this be retried?" are separate axes. This bug happened because the two had been crammed into a single &lt;code&gt;RAN&lt;/code&gt; marker, and the &lt;code&gt;quota-catchup.py&lt;/code&gt; side never anticipated the &lt;code&gt;exit≠0&lt;/code&gt; case. When designing log markers, it's safer not to let "what actually happened" and "what the downstream batch should do next" share the same string.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The circuit stops everything uniformly, but jobs that can't recover a missed opportunity — like posting — can hold one attempt every 30 minutes via &lt;code&gt;--priority&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;The attempt right is managed by &lt;code&gt;claim_priority_probe&lt;/code&gt; through &lt;code&gt;last_priority_probe&lt;/code&gt; in &lt;code&gt;locked_state&lt;/code&gt;, and it is &lt;strong&gt;shared across all priority jobs, not per job&lt;/strong&gt;. If multiple priority jobs land in the same window, there's only one opportunity&lt;/li&gt;
&lt;li&gt;The child process that wins the attempt must receive &lt;code&gt;CLAUDE_QUOTA_PRIORITY_PROBE=1&lt;/code&gt;. Forget it, and the inner &lt;code&gt;run_claude&lt;/code&gt; rejects the call at the second circuit check&lt;/li&gt;
&lt;li&gt;When the probe comes up empty, record it as &lt;code&gt;CLAUDE_QUOTA_JOB_SKIPPED reason=priority-probe-quota&lt;/code&gt;, not &lt;code&gt;CLAUDE_QUOTA_JOB_RAN exit≠0&lt;/code&gt;. The re-run check in &lt;code&gt;quota-catchup.py&lt;/code&gt; only looks at the marker type and never at the &lt;code&gt;exit&lt;/code&gt; code&lt;/li&gt;
&lt;li&gt;Design log markers so that "what actually happened" and "what the downstream batch should do" are kept separate&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next time, I'll cover how &lt;code&gt;quota-catchup.py&lt;/code&gt; picks up these &lt;code&gt;SKIPPED&lt;/code&gt; markers and &lt;a href="https://dev.to/bokuwalily/15-launchd-jobs-and-one-quota-circuit-breaker-deciding-what-to-re-run-once-the-circuit-closes-3f95"&gt;decides how much to re-run after recovery&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you run a circuit breaker in front of your own scheduled jobs: does your downstream retry logic distinguish "ran and failed" from "never really got a chance"?&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by **Lily&lt;/em&gt;* — I ship iOS apps and automate my content stack with Claude Code.&lt;br&gt;
Follow along: &lt;a href="https://bokuwalily.com" rel="noopener noreferrer"&gt;Portfolio&lt;/a&gt; · &lt;a href="https://x.com/bokuwalily" rel="noopener noreferrer"&gt;X&lt;/a&gt; · &lt;a href="https://github.com/bokuwalily" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;*&lt;/p&gt;

</description>
      <category>python</category>
      <category>automation</category>
      <category>launchd</category>
      <category>claudecode</category>
    </item>
    <item>
      <title>13 Days of Silence: How One Bad Draft Froze an Entire DM Lane</title>
      <dc:creator>Lily</dc:creator>
      <pubDate>Sat, 19 Sep 2026 11:00:05 +0000</pubDate>
      <link>https://dev.to/bokuwalily/13-days-of-silence-how-one-bad-draft-froze-an-entire-dm-lane-52jd</link>
      <guid>https://dev.to/bokuwalily/13-days-of-silence-how-one-bad-draft-froze-an-entire-dm-lane-52jd</guid>
      <description>&lt;p&gt;Last time I wrote about a &lt;a href="https://dev.to/bokuwalily/5-pitfalls-i-hit-building-a-daemon-that-auto-approves-claude-codes-computer-use-dialog-5amk"&gt;Terminal-watching daemon that auto-clicks Computer Use approval dialogs&lt;/a&gt;. This post is the same genre—an unattended job dying quietly—but the culprit wasn't a gap in monitoring. It was &lt;strong&gt;the design itself&lt;/strong&gt;. founder-scout's automated DM lane sent &lt;strong&gt;zero messages for 13 days because of a single bad record, and nobody noticed&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: the exit code was there, but nobody saw it
&lt;/h2&gt;

&lt;p&gt;founder-scout has a &lt;code&gt;followers&lt;/code&gt; lane that sends a first DM to new followers of &lt;a class="mentioned-user" href="https://dev.to/bokuwalily"&gt;@bokuwalily&lt;/a&gt;. When I checked on 2026-09-10, I found that &lt;strong&gt;the last successful send was 8/28 at 16:35&lt;/strong&gt;. Thirteen days, not a single DM.&lt;/p&gt;

&lt;p&gt;The queue (&lt;code&gt;state/followers_queue.jsonl&lt;/code&gt;) kept growing with every run, from 336 entries to 390. In other words, &lt;strong&gt;drafts were being generated daily and the send script was launching daily&lt;/strong&gt;. Still zero sends. The logs recorded &lt;code&gt;process.exit(3)&lt;/code&gt; day after day, but that abort path was never wired to a Discord alert, so it never crossed anyone's eyes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Root cause: turning "one bad record" into "everything stops"
&lt;/h2&gt;

&lt;p&gt;On 8/28 I wired a number-gate (&lt;code&gt;checkColdOutbound&lt;/code&gt;) into all seven outbound paths to stop drafts from leaking my actual download counts or revenue figures. At that time I designed &lt;code&gt;followers_send.mjs&lt;/code&gt; and &lt;code&gt;dm.mjs&lt;/code&gt; so that &lt;strong&gt;if even one draft in the batch leaked a number, the script would immediately &lt;code&gt;process.exit(3)&lt;/code&gt; and halt everything&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The leaking draft was addressed to @kouAI_work and contained the phrase "downloads on August 25…". That one record was never written to the ledger, so &lt;strong&gt;every run picked the same six entries from the head of the queue, and every run stopped on the same one&lt;/strong&gt;. The sendable drafts behind it were never evaluated once—they just kept piling up in the queue.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note&lt;/strong&gt;&lt;br&gt;
The same day, a different incident of the same shape happened on the &lt;code&gt;dm.mjs&lt;/code&gt; side. Composer timeouts for the same three people counted as three consecutive failures and tripped the stop guard, so the four people behind them didn't get sent for two runs in a row. Different cause, but the same pattern: &lt;strong&gt;the head of the queue gets pinned → the job stops there every time&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Fail-closed is for stopping &lt;em&gt;that one record&lt;/em&gt;
&lt;/h2&gt;

&lt;p&gt;This is the lesson. Pre-send checks like the number-gate or composer validation exist to &lt;strong&gt;keep one dangerous message from going out&lt;/strong&gt;. But if you implement them as "any anomaly → exit the whole thing," then unless that anomaly heals itself, &lt;strong&gt;the entire lane stops permanently&lt;/strong&gt;. An unrecorded anomaly stays parked at the head of the queue, so it never heals itself.&lt;/p&gt;

&lt;p&gt;The fix has two parts.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Record the dropped row in the ledger and treat it as exhausted
&lt;/h3&gt;

&lt;p&gt;Here's the relevant section of &lt;code&gt;src/followers_send.mjs&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/followers_send.mjs:84-97&lt;/span&gt;
&lt;span class="c1"&gt;// 送信前ゲート: 自分のDL数・売上の実数が入っている下書きは「その1件だけ」外して台帳に記録する。&lt;/span&gt;
&lt;span class="c1"&gt;// 1件で全体を止めると、その下書きがキュー先頭に残り続けてレーンごと止まる&lt;/span&gt;
&lt;span class="c1"&gt;// （実測2026-08-28〜09-10: kouAI_work 1件で13日間0通）。&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;gateAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;leaks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;drafts&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;who&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;handle&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;?&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;bad&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;checkColdOutbound&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dm&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bad&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;leaks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`number gate: 除外 @&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;who&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; — &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bad&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;why&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;appendSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SENT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;who&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;number-gate: &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bad&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;why&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)`&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;follow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;skipped&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;gateAt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;lane&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;LANE&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;exhausted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;who&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sendable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;drafts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;exhausted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;batch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sendable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;perRun&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;laneLeft&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;accountLeft&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;process.exit(3)&lt;/code&gt; is gone. Instead, it writes &lt;code&gt;ok:false, reason:'number-gate: …'&lt;/code&gt; to the &lt;code&gt;SENT&lt;/code&gt; ledger, marks only that one entry as &lt;code&gt;exhausted&lt;/code&gt;, and the rest proceed to the batch as usual. &lt;code&gt;dm.mjs&lt;/code&gt; got exactly the same treatment.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/dm.mjs:82-94&lt;/span&gt;
&lt;span class="c1"&gt;// 送信前ゲート: 数字漏れの下書きは「その1件だけ」外して台帳に記録する。全体を止めると&lt;/span&gt;
&lt;span class="c1"&gt;// 先頭に残り続けてレーンごと止まる（followers レーンで13日間0通の実測あり）。&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;gateAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;leaks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;queue&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;who&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;handle&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;?&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;bad&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;checkColdOutbound&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dm&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bad&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;leaks&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`number gate: 除外 @&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;who&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; — &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bad&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;why&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;recordSent&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;who&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;number-gate: &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bad&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;why&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)`&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;follow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;skipped&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;gateAt&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nx"&gt;exhausted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;who&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sendable&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;exhausted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because it's now in the ledger, from the next run onward this entry is excluded at the point where the &lt;code&gt;exhausted&lt;/code&gt; set is built (&lt;code&gt;followers_send.mjs:49-51&lt;/code&gt; and &lt;code&gt;dm.mjs:50-52&lt;/code&gt;, where the string &lt;code&gt;number-gate&lt;/code&gt; is picked out of &lt;code&gt;reason&lt;/code&gt; and added to &lt;code&gt;exhausted&lt;/code&gt;). &lt;strong&gt;The moment it was recorded, this anomaly became one that "automatically disappears on the next run."&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Move anyone with a failure history to the back of the queue
&lt;/h3&gt;

&lt;p&gt;The other fix is a reorder so that the three-consecutive-failures guard (&lt;code&gt;break&lt;/code&gt; when &lt;code&gt;consecutiveFailures &amp;gt;= 3&lt;/code&gt;) doesn't keep hitting &lt;strong&gt;the same faces every time&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/followers_send.mjs:81-83&lt;/span&gt;
&lt;span class="c1"&gt;// 一度失敗した相手は後ろへ回す。先頭の同じ数人が毎run失敗すると「3連続失敗で停止」に&lt;/span&gt;
&lt;span class="c1"&gt;// 当たり、後ろの送れる人まで道連れになる（実測2026-09-10: 同じ3人で2run連続0通）。&lt;/span&gt;
&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;attempts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;attempts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It just sorts by &lt;code&gt;attempts&lt;/code&gt; (the number of past send attempts) in ascending order. Now people who are &lt;strong&gt;structurally destined to keep failing&lt;/strong&gt;—DMs not open, composer timing out—no longer monopolize the head of the queue, and the sendable people behind them no longer get dragged down every run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Monitoring: an exit code alone doesn't mean "it's working"
&lt;/h2&gt;

&lt;p&gt;The reason this went unnoticed for 13 days is that &lt;strong&gt;the logs from the broken abort path only lived locally&lt;/strong&gt;. Seeing "loaded" in &lt;code&gt;launchctl list&lt;/code&gt; or &lt;code&gt;pm2 list&lt;/code&gt; only means the job is executing; whether it's producing results is a separate question.&lt;/p&gt;

&lt;p&gt;You need to check three things together: ① is the job loaded, ② what's the distribution of exit codes in recent logs, ③ what does the actual platform show (in this case, the real count of DMs sent). In this incident, ① and ② looked fine, and it was only when I checked ③ that "zero DMs in 13 days" came to light. On top of that, I made every run log &lt;code&gt;laneToday&lt;/code&gt; (the lane's send count for the day) and added an operational rule: &lt;strong&gt;three consecutive days at 0 is an anomaly&lt;/strong&gt;. The log line at &lt;code&gt;followers_send.mjs:101&lt;/code&gt; is what feeds that.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`queue=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;sendable&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; laneToday=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;laneToday&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;perDay&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; accountToday=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;sentToday&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;limits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dmPerDay&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; thisRun=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; held=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;held&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Pitfalls I hit
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Designing &lt;code&gt;process.exit(3)&lt;/code&gt; on a single anomaly&lt;/strong&gt; → an unrecorded anomaly stays parked at the head of the queue and the lane stops permanently&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forgetting to wire a Discord alert to the abort path&lt;/strong&gt; → an exit code in a log nobody reads is the same as silence&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skipping the dropped row without writing it to the ledger&lt;/strong&gt; → the next run re-evaluates the same draft in the same position and gets stuck on the same one every time&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The three-consecutive-failures guard hitting the same pinned people every run&lt;/strong&gt; → simply moving anyone with a failure history to the back keeps the sendable people behind them from being taken down with them&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Judging "running" from the loaded state in &lt;code&gt;launchctl list&lt;/code&gt; alone&lt;/strong&gt; → it's only healthy once you've checked the exit codes and the real counts on the platform side&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A fail-closed gate is written to &lt;strong&gt;stop that one record&lt;/strong&gt;, not to &lt;strong&gt;stop the lane&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Halt-everything plus not-recorded equals &lt;strong&gt;permanent stop&lt;/strong&gt; unless it clears on its own next run. Whenever you write a halt, ask: "Will this stop condition naturally disappear on the next run?"&lt;/li&gt;
&lt;li&gt;The fix was a two-line prescription: &lt;strong&gt;write the dropped row to the ledger with &lt;code&gt;ok:false, reason&lt;/code&gt; and mark it exhausted&lt;/strong&gt;, and &lt;strong&gt;sort anyone with a failure history to the back of the queue by ascending attempts&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;The loaded state in &lt;code&gt;launchctl list&lt;/code&gt; or an &lt;code&gt;exit 0&lt;/code&gt; isn't enough for monitoring. Only when you've looked at &lt;strong&gt;the distribution of exit codes plus the real counts on the platform&lt;/strong&gt; can you say "it's working"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do you have an unattended job right now whose "healthy" status you've only ever verified from the process list—and when did you last check the actual output on the other end?&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by **Lily&lt;/em&gt;* — I ship iOS apps and automate my content stack with Claude Code.&lt;br&gt;
Follow along: &lt;a href="https://bokuwalily.com" rel="noopener noreferrer"&gt;Portfolio&lt;/a&gt; · &lt;a href="https://x.com/bokuwalily" rel="noopener noreferrer"&gt;X&lt;/a&gt; · &lt;a href="https://github.com/bokuwalily" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;*&lt;/p&gt;

</description>
      <category>automation</category>
      <category>node</category>
      <category>devops</category>
      <category>ai</category>
    </item>
    <item>
      <title>3 Weeks of Silent Failure: How One Missing `--model` Flag Drained My Interactive Quota Across 4 Jobs</title>
      <dc:creator>Lily</dc:creator>
      <pubDate>Sat, 19 Sep 2026 05:00:06 +0000</pubDate>
      <link>https://dev.to/bokuwalily/3-weeks-of-silent-failure-how-one-missing-model-flag-drained-my-interactive-quota-across-4-jobs-1gp2</link>
      <guid>https://dev.to/bokuwalily/3-weeks-of-silent-failure-how-one-missing-model-flag-drained-my-interactive-quota-across-4-jobs-1gp2</guid>
      <description>&lt;p&gt;I went from ¥100k a month as a student to ¥600k juggling side gigs, then to ¥0 after a layoff—and six months of building an autonomous Claude Code environment brought me to ¥1.2M a month in revenue. The difference wasn't working harder. It was betting on "grow the environment" instead of "do the work."&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Setup Works
&lt;/h2&gt;

&lt;p&gt;Look up how to make money on the side and you'll always land on the same three options: sell your skills, make content, or build a service. They're all correct. But they share one ceiling: &lt;strong&gt;your time is finite.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Back when I was juggling gigs at ¥600k a month, I genuinely hit the wall on working hours. Every article, every DM, every proposal needed my own hands. The more clients I had, the more quality slipped. I couldn't add more, and I couldn't let quality drop—and that's exactly when the layoff came. I decided I wasn't going to do it the same way again.&lt;/p&gt;

&lt;p&gt;For the restart, I chose a different approach: &lt;strong&gt;build the environment that runs without me first.&lt;/strong&gt; Today, &lt;code&gt;launchd&lt;/code&gt; jobs fire at 8:00 and 10:35 every morning, generate a stock of articles, and post a notification to Discord. Whether my Mac is open or I'm asleep, the article stock keeps growing.&lt;/p&gt;

&lt;p&gt;There are three reasons this setup is strong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Time accumulates.&lt;/strong&gt; Human work consumes time. Environment work produces it. Every job run stacks up as inventory that doesn't disappear. Even when I'm sick and can't do anything for three days, the jobs quietly keep growing the stock. "As long as they're running correctly," that is—and that "if" is the whole subject of this post.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The scale changes by an order of magnitude.&lt;/strong&gt; A human can write a few articles a day at most. Jobs run in parallel, as many as you like. Right now, four lanes—article generation, daily PDCA, structuring external data, and triaging incoming DMs—all run during the same night. I passed the throughput of a single person because agents were lined up side by side.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How deeply you understand quota structure changes.&lt;/strong&gt; Someone who uses Claude Code as a "tool to ask the AI" and someone who wires it in as "part of the infrastructure" have fundamentally different monthly throughput. To use it as infrastructure, &lt;strong&gt;you have to know exactly how quota is shared and how it's consumed, or you will get stuck.&lt;/strong&gt; This outage happened precisely in that gap of knowledge.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Overall Flow
&lt;/h2&gt;

&lt;p&gt;Here's the skeleton of the content-generation pipeline up front. I'll dissect the outage in the second half, so having the big picture first will make it easier to follow.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;launchd
 ~/Library/LaunchAgents/com.shun.article-daily.plist
 起動タイミング: 08:00 / 10:35（毎日）
     │
     ▼
 ~/.claude/scripts/claude-quota-guard.py
     --job com.shun.article-daily
     │  ← クォータ残量のガード層
     ▼
 ~/.discord/run-and-notify.sh
     "zenn" "Zenn記事ストック生成"
     │  ← Discord通知ラッパー
     ▼
 ~/.claude/scripts/article-daily-stock.sh  apply
     │  ← 実際の生成ロジック
     ▼
 claude -p "プロンプト..." [--model ???]
     │
     ├─ --model を明示した場合
     │       指定モデルの独立した枠で実行
     │       → 記事テキストが正常に返る ✅
     │
     └─ --model を省略した場合
             対話セッションの「既定モデル枠」を共有消費
             → 枠が切れると以下の1行だけ stdout に吐いて終了:
                 "You've reached your Fable limit."
             → exit code は非0
             → ただしログには「1行返ってきた」としか残らない
             → [ -s output ] 等の素朴なチェックを通り抜ける ❌
             → 何週間でも誰も気づかない ← 実際に3週間気づかなかった
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bottom of the diagram is the heart of the outage. &lt;code&gt;claude -p&lt;/code&gt; does not error when you omit &lt;code&gt;--model&lt;/code&gt;. It quietly goes off and consumes the interactive session's quota. When that quota runs out, it returns a single-line message and exits. That one line gets mistaken for "there was output," and detection slips through.&lt;/p&gt;

&lt;h3&gt;
  
  
  The plist Implementation
&lt;/h3&gt;

&lt;p&gt;Here's an excerpt from the actual &lt;code&gt;com.shun.article-daily.plist&lt;/code&gt; (real paths replaced with &lt;code&gt;~&lt;/code&gt;).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;ProgramArguments&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;array&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;~/.claude/scripts/claude-quota-guard.py&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;--job&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;com.shun.article-daily&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;--&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;/bin/bash&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;~/.discord/run-and-notify.sh&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;zenn&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;Zenn記事ストック生成&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;/bin/bash&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;~/.claude/scripts/article-daily-stock.sh&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;apply&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/array&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;StartCalendarInterval&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;array&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;dict&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;Hour&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;8&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;Minute&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;0&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;dict&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;Hour&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;10&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;Minute&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;35&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/array&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;StandardOutPath&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;~/.claude/logs/article-daily.out.log&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;StandardErrorPath&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;~/.claude/logs/article-daily.err.log&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;ProcessType&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;string&amp;gt;&lt;/span&gt;Background&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;LowPriorityIO&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;true/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;Nice&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;10&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ProcessType: Background&lt;/code&gt;, &lt;code&gt;LowPriorityIO: true&lt;/code&gt;, and &lt;code&gt;Nice: 10&lt;/code&gt; go in as a set. By explicitly telling the OS I/O scheduler "this is a low-priority background task," they keep the job from interfering with foreground work (opening Figma, looking something up in Safari).&lt;/p&gt;

&lt;p&gt;The call chain is four levels deep: &lt;code&gt;launchd → quota-guard → run-and-notify → article-daily-stock&lt;/code&gt;. &lt;code&gt;claude -p&lt;/code&gt; is invoked inside &lt;code&gt;article-daily-stock.sh&lt;/code&gt;, and whether &lt;code&gt;--model&lt;/code&gt; is present there is the whole question.&lt;/p&gt;

&lt;h3&gt;
  
  
  Four Lanes Surfaced at Once on September 3, 2026
&lt;/h3&gt;

&lt;p&gt;In my case, the same omission &lt;strong&gt;surfaced in four jobs simultaneously.&lt;/strong&gt; Quoting the record left in my knowledge base:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Lane&lt;/th&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Damage window&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;gen-column.mjs&lt;/code&gt; (LINE column generation)&lt;/td&gt;
&lt;td&gt;exit 1 every morning with the one-line "Fable limit". &lt;strong&gt;Stock dwindled to 2 remaining&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;2026/8/13–9/3 (&lt;strong&gt;3 weeks&lt;/strong&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;triage&lt;/code&gt; (incoming DM classification)&lt;/td&gt;
&lt;td&gt;Every quota-exhausted run fell through to "unclassifiable"; &lt;strong&gt;not a single incoming DM had been auto-replied since 9/2&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;9/2–&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;brand-404&lt;/code&gt; (IG daily PDCA)&lt;/td&gt;
&lt;td&gt;Zero output due to &lt;code&gt;Fable limit&lt;/code&gt;. That day's action items and daily report never generated&lt;/td&gt;
&lt;td&gt;9/2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;hosei-grad-planner&lt;/code&gt; (PDF structuring)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;All 3 runs&lt;/strong&gt; at 00:21 / 04:30 / 10:30 produced zero structured data&lt;/td&gt;
&lt;td&gt;9/3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For three weeks, &lt;code&gt;gen-column.mjs&lt;/code&gt; burned through its stock every morning while "looking like it was working fine." It was only discovered &lt;strong&gt;when the stock hit 2 remaining.&lt;/strong&gt; The job's log recorded "1 line returned." Nobody was reading what that one line said.&lt;/p&gt;

&lt;p&gt;"Four lanes at once" is an important fact too. If just one job breaks, it's easy to notice. When several go silent at the same time, the unease of "everything's too quiet" fades. If anything, it pulls you toward the misreading that "automation is working and my hands are free."&lt;/p&gt;

&lt;p&gt;The root cause is &lt;strong&gt;a code defect: not writing &lt;code&gt;--model&lt;/code&gt;.&lt;/strong&gt; Combined with &lt;strong&gt;lax log monitoring that treats "one line = success,"&lt;/strong&gt; it went undetected for three weeks. Each hole on its own would have been easy to spot, yet stacked together they created a blind spot.&lt;/p&gt;

&lt;p&gt;I'll dissect that structure in the second half: why omitting &lt;code&gt;--model&lt;/code&gt; is so hard to notice, how to build one script that makes detection reliable, and how to implement a &lt;strong&gt;grep gate that mechanically rejects&lt;/strong&gt; any existing plist with the flag missing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation Details
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Where &lt;code&gt;--model&lt;/code&gt; Goes and How to Write It
&lt;/h3&gt;

&lt;p&gt;Looking at &lt;code&gt;article-daily-stock.sh&lt;/code&gt; after the post-outage fix, there are two places where &lt;code&gt;claude -p&lt;/code&gt; is called: &lt;strong&gt;topic planning&lt;/strong&gt; (auto-generating the next topic when the queue is empty) and &lt;strong&gt;article body generation&lt;/strong&gt;. Both have &lt;code&gt;--model&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# キューが空のとき: 実作業からネタを自動立案（article-daily-stock.sh 327-330行目）&lt;/span&gt;
&lt;span class="nv"&gt;TOPIC_JSON&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;run_to 600 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CLAUDE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$REPLENISH_PROMPT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--strict-mcp-config&lt;/span&gt; &lt;span class="nt"&gt;--mcp-config&lt;/span&gt; &lt;span class="s1"&gt;'{"mcpServers":{}}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--model&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ARTICLE_MODEL&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;sonnet&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--effort&lt;/span&gt; high &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output-format&lt;/span&gt; text &lt;span class="nt"&gt;--allowedTools&lt;/span&gt; &lt;span class="s2"&gt;"Read,Grep,Glob,Bash"&lt;/span&gt; &lt;span class="nt"&gt;--max-turns&lt;/span&gt; 20 2&amp;gt;&amp;gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 記事本文の生成（article-daily-stock.sh 397-401行目）&lt;/span&gt;
run_to 1500 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CLAUDE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROMPT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--strict-mcp-config&lt;/span&gt; &lt;span class="nt"&gt;--mcp-config&lt;/span&gt; &lt;span class="s1"&gt;'{"mcpServers":{}}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--model&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ARTICLE_MODEL&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;sonnet&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--effort&lt;/span&gt; high &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output-format&lt;/span&gt; text &lt;span class="nt"&gt;--allowedTools&lt;/span&gt; &lt;span class="s2"&gt;"Read,Grep,Glob,Write,Bash"&lt;/span&gt; &lt;span class="nt"&gt;--max-turns&lt;/span&gt; 40 &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$LOG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;&amp;amp;1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;||&lt;/span&gt; log &lt;span class="s2"&gt;"WARN: claude -p exit非0（検証で弾く）"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key is the &lt;code&gt;"${ARTICLE_MODEL:-sonnet}"&lt;/code&gt; form. Even if the &lt;code&gt;ARTICLE_MODEL&lt;/code&gt; environment variable is undefined, it falls back to &lt;code&gt;sonnet&lt;/code&gt;, so the "forgot it and fell through to the default quota" accident can't happen. It also satisfies a separate operational rule: "don't hardcode model IDs in code." If you embed a model ID as a string, you end up rewriting every script when the vendor retires an alias. Make it switchable through a single variable and you can flip every lane with one line: &lt;code&gt;launchctl setenv ARTICLE_MODEL haiku&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One more thing: I always include the combination &lt;code&gt;--strict-mcp-config --mcp-config '{"mcpServers":{}}'&lt;/code&gt;. Launching from launchd doesn't go through a login shell, so the &lt;code&gt;.mcp.json&lt;/code&gt; in the home directory gets read. If it contains external MCP server configuration, the batch may hit external networks unintentionally. Explicitly passing an empty server config completely blocks external MCP calls inside the batch.&lt;/p&gt;

&lt;h3&gt;
  
  
  Designing &lt;code&gt;strict_quota_message&lt;/code&gt;—The 400-Character Wall
&lt;/h3&gt;

&lt;p&gt;Inside &lt;code&gt;claude-quota-guard.py&lt;/code&gt; there's a function called &lt;code&gt;strict_quota_message()&lt;/code&gt;. This is the single most important piece of detection logic for this incident.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# claude-quota-guard.py 41-47行目
&lt;/span&gt;&lt;span class="n"&gt;STRICT_QUOTA_PATTERNS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(?:you(?:&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ve| have) )?hit your (?:weekly |5-?hour |usage |session )?limit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;claude (?:ai )?usage limit reached&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;approaching your (?:weekly |usage )?limit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;STRICT_QUOTA_MAX_CHARS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# claude-quota-guard.py 159-170行目
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;strict_quota_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;成功終了(exit 0)の本文が「上限メッセージそのもの」かを判定する。

    上限本文は短く、その一文だけが返る。記事本文にたまたま同じ語が出ても発火させない。
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;trimmed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;trimmed&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;trimmed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;STRICT_QUOTA_MAX_CHARS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;trimmed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flags&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IGNORECASE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;pattern&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;STRICT_QUOTA_PATTERNS&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;STRICT_QUOTA_MAX_CHARS = 400&lt;/code&gt; cap is the linchpin of the design. When Claude hits a limit, the string it returns is a single sentence like "You've reached your Fable limit." or "You've hit your weekly limit."—a few dozen characters at most. A properly generated article body, on the other hand, is almost never under 400 characters. This &lt;strong&gt;length asymmetry&lt;/strong&gt; is what mechanically separates "this is the limit message itself" from "this is an article body that mentions limits."&lt;/p&gt;

&lt;p&gt;The regex patterns are designed with the same thinking. The loose &lt;code&gt;QUOTA_PATTERNS&lt;/code&gt; (used when exit is non-zero) and the strict &lt;code&gt;STRICT_QUOTA_PATTERNS&lt;/code&gt; (used when exit is 0) are kept separate to prevent a false positive where an article whose subject happens to be "quota" trips my own circuit breaker. In fact, during the period when this check was still loose, I had an incident where "an article written about quota tripped the circuit breaker," which is how I settled on the current implementation that switches detection logic based on exit 0 vs. non-zero.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Postcondition Gate—"There Was Output" Is Not Success
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;article-daily-stock.sh&lt;/code&gt; includes a function called &lt;code&gt;article_ok()&lt;/code&gt; (lines 133–181). Even if &lt;code&gt;claude -p&lt;/code&gt; returns a healthy exit code, no article is accepted unless it passes this function.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# article-daily-stock.sh 133行目&lt;/span&gt;
&lt;span class="nv"&gt;MIN_ARTICLE_BYTES&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# article-daily-stock.sh 173-181行目&lt;/span&gt;
article_ok&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;f&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;return &lt;/span&gt;1
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;stat&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt;%z &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo &lt;/span&gt;0&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-ge&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MIN_ARTICLE_BYTES&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;return &lt;/span&gt;1
  &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-qE&lt;/span&gt; &lt;span class="s1"&gt;'^title:'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;return &lt;/span&gt;1
  frontmatter_title_ok &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;return &lt;/span&gt;1
  &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-qiE&lt;/span&gt; &lt;span class="s1"&gt;'request timed out|不明な商品|TODO: *本文|\(生成失敗\)'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;return &lt;/span&gt;1
  &lt;span class="k"&gt;return &lt;/span&gt;0
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At least 1200 bytes, a &lt;code&gt;title:&lt;/code&gt; frontmatter key present, a title of 70 characters or fewer, and no timeout phrasing mixed in—only when all four conditions are met is the run recorded as "generation succeeded." This validation block sits immediately after &lt;code&gt;claude -p&lt;/code&gt;, and if the file doesn't meet the bar, it's deleted and the script exits (lines 411–415). So as not to waste the day's slot, the marker file (&lt;code&gt;~/.claude/logs/.article-daily-done-${TODAY}&lt;/code&gt;) is designed to be created "only after validation passes" (line 509).&lt;/p&gt;

&lt;p&gt;A postcondition is evidence that "generation completed correctly." Not whether a file exists, but &lt;strong&gt;whether the file's contents have the expected quality&lt;/strong&gt;, verified mechanically. Since switching to this mindset, the misreading of "the job ran = output was stocked" has gone away.&lt;/p&gt;

&lt;h3&gt;
  
  
  A grep Gate That Mechanically Catches the Omission
&lt;/h3&gt;

&lt;p&gt;It's hard for a human to find a missing &lt;code&gt;--model&lt;/code&gt;. The more files you have, the more visual inspection breaks down. The following one-liner sweeps across &lt;code&gt;~/.claude/scripts/&lt;/code&gt; and &lt;code&gt;~/Library/LaunchAgents/&lt;/code&gt; and extracts "lines that have &lt;code&gt;claude -p&lt;/code&gt; but no &lt;code&gt;--model&lt;/code&gt;."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# plist配下のジョブスクリプトで --model を書き忘れている箇所を抽出する&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"claude -p"&lt;/span&gt; ~/.claude/scripts/ &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s1"&gt;'--model'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s1"&gt;'^Binary'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ideally this would go into CI or a pre-commit hook, but launchd jobs aren't connected to normal CI. Instead I use the following approach. During the weekly &lt;code&gt;launchctl list | grep com.shun&lt;/code&gt; check that confirms all jobs exist, I run the grep above alongside it. Zero lines back means everything's safe. Even one line back means something's missing somewhere.&lt;/p&gt;

&lt;p&gt;In cases of multi-level calls—a script called from a plist calls a script which calls another—you need to follow the chain recursively down to the leaf script.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 多段呼び出しも含めて再帰的に検索&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"claude -p&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;claude --print"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  ~/.claude/scripts/ &lt;span class="se"&gt;\&lt;/span&gt;
  ~/dev/ &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.sh"&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.mjs"&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.js"&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.py"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s1"&gt;'--model'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s1"&gt;'#.*claude -p'&lt;/span&gt;   &lt;span class="c"&gt;# コメント行は除外&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without narrowing target extensions via &lt;code&gt;--include&lt;/code&gt;, the results balloon. The four types &lt;code&gt;.sh&lt;/code&gt; &lt;code&gt;.mjs&lt;/code&gt; &lt;code&gt;.js&lt;/code&gt; &lt;code&gt;.py&lt;/code&gt; covered 90% of my environment.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where I Got Stuck
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Three Weeks Without Noticing, Until Stock Hit 2
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;gen-column.mjs&lt;/code&gt; is a job that generates one LINE column every morning. It was supposed to raise an alert once stock dropped below 5, but for some reason it never fired. When stock hit &lt;strong&gt;2&lt;/strong&gt;, I was manually looking at Discord notifications and finally thought, "something's wrong."&lt;/p&gt;

&lt;p&gt;Digging through the logs, every morning looked like this.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[2026-08-14 08:03:12] claude -p "..." 実行
[2026-08-14 08:03:19] 出力: "You've reached your Fable limit."
[2026-08-14 08:03:19] 出力文字数: 32
[2026-08-14 08:03:19] ジョブ完了
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The job "completed" and returned exit 0, so quota-guard's tally recorded it as "success." The stock-count logic was adding "normal completion = 1 article generated," so the stock count appeared to be increasing. In reality, not a single one was added.&lt;/p&gt;

&lt;p&gt;What exposed it was counting the &lt;strong&gt;actual number of files&lt;/strong&gt; in stock directly with &lt;code&gt;ls&lt;/code&gt;. It was 2. The gap between the job's success count and the real file count was the evidence.&lt;/p&gt;

&lt;p&gt;After that, I made two changes: stock monitoring uses the real file count instead of a counter, and a postcondition check runs after generation. A counter is a declaration of intent to "generate"; it is not evidence that generation "succeeded."&lt;/p&gt;

&lt;h3&gt;
  
  
  The Wording Changed and the Circuit Breaker Let It Straight Through
&lt;/h3&gt;

&lt;p&gt;On September 2, 2026, it came to light that &lt;code&gt;QUOTA_PATTERNS&lt;/code&gt; in claude-quota-guard.py did not include &lt;code&gt;session limit&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That day's error message looked like this.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;You've hit your session limit · resets 6:50pm (Asia/Tokyo)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the wording for the "5-hour session limit." It's phrased differently from the earlier "weekly limit" or "Fable limit." Because &lt;code&gt;QUOTA_PATTERNS&lt;/code&gt; at the time didn't have this pattern, quota detection slipped through. As a result, every job that day—including 3 note lanes—kept firing blanks against the limit, and 306 lines of wasted Claude calls piled up over 4 hours.&lt;/p&gt;

&lt;p&gt;The fix was adding &lt;code&gt;r"resets?\s+(?:at\s+)?\d{1,2}(?::\d{2})?\s*[ap]m"&lt;/code&gt; to &lt;code&gt;QUOTA_PATTERNS&lt;/code&gt; (claude-quota-guard.py line 37). It's a pattern that catches the combination of the verb "resets" and a time in am/pm format. That line is still in the code today, with a comment recording the live account: "2026-09-02: the 5-hour limit comes back in the form of ~."&lt;/p&gt;

&lt;p&gt;The lesson here is the principle that &lt;strong&gt;quota detection must not depend on the vendor's wording.&lt;/strong&gt; Vendors change wording. They don't announce it. Anthropic has already used multiple phrasings: "You've reached your limit," "You've hit your Fable limit," "You've hit your session limit." Pattern matching needs to be written on the "structural features of the wording," not "exact match of the wording."&lt;/p&gt;

&lt;h3&gt;
  
  
  The Circuit Breaker Stopped the Posting Jobs Too
&lt;/h3&gt;

&lt;p&gt;On August 21, 2026, when the circuit breaker opened (= detected quota exhaustion), a problem arose where not only generation jobs but &lt;strong&gt;posting jobs stopped as well.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;There's a record in the comments of claude-quota-guard.py.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# 🔴 2026-08-21: circuit が開くと全ジョブが一律で止まるため、消費の大半を占める
# 返信/エンゲージ系がクォータを使い切った巻き添えで「投稿」まで停止していた。
# 実測(launchd.log 累計): xpilot.autopost は 231実行/308スキップ＝実行率43%で、
# threadspilot.engage(64%) より優先度が低い扱いになっていた。投稿はその時間帯を逃すと
# 二度と埋まらないので...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Timing is everything for posting. A post that misses its 8 a.m. slot won't have the same effect if it goes up at night. Its priority is fundamentally different from a generation job's.&lt;/p&gt;

&lt;p&gt;The cause was a design in which "the circuit breaker stops all jobs without distinction." The generation jobs were the ones that exhausted the quota, yet the posting jobs got taken down as collateral.&lt;/p&gt;

&lt;p&gt;The remedy was adding a &lt;code&gt;--priority&lt;/code&gt; flag (it remains in the current code as the &lt;code&gt;priority: bool&lt;/code&gt; argument of &lt;code&gt;run_job()&lt;/code&gt;). Priority jobs are allowed, via &lt;code&gt;claim_priority_probe()&lt;/code&gt;, to attempt one run per &lt;code&gt;PRIORITY_PROBE_INTERVAL&lt;/code&gt; (default 30 minutes) even while the circuit is open (claude-quota-guard.py lines 454–468). If the attempt succeeds, it's judged that "quota has recovered" and the circuit closes. With this design, one mechanism achieves two goals: "posting doesn't stop, and recovery is detected early."&lt;/p&gt;

&lt;h3&gt;
  
  
  Exit 0, but the Output Was a Quota Message
&lt;/h3&gt;

&lt;p&gt;The hardest pattern to find was "exit code 0 with a limit message on stdout." This happens not only when &lt;code&gt;--model&lt;/code&gt; is omitted, but also under certain conditions where claude returns the quota message as a "success."&lt;/p&gt;

&lt;p&gt;The original quota-guard recorded exit code 0 as "success" and leaned toward closing the circuit. So when a limit message came back with exit 0, the circuit didn't open, the job was recorded as "success," and the actual output was just the one limit line—a state that could persist for cycle after cycle.&lt;/p&gt;

&lt;p&gt;Introducing &lt;code&gt;strict_quota_message()&lt;/code&gt; (the 400-character check described earlier) was the direct fix for this. Even with exit 0, if stdout meets that function's conditions, it's treated as quota detection and the circuit opens (claude-quota-guard.py lines 187–190).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;is_quota_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;returncode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;returncode&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;quota_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="c1"&gt;# ゆるいパターン
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;strict_quota_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;     &lt;span class="c1"&gt;# 厳しいパターン（400字以下＋構造マッチ）
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Judge by &lt;strong&gt;both&lt;/strong&gt; the exit code and the output content. Since this design went in, there have been no more misses from "exit 0 false successes."&lt;/p&gt;




&lt;p&gt;Looking back now, all four failures grew from the same single root: the property that "&lt;code&gt;claude -p&lt;/code&gt; doesn't error even with incomplete arguments." Incomplete arguments, unexpected behavior—Claude returns something regardless. Because that "something" gets recorded as output, everything looks normal if monitoring is lax.&lt;/p&gt;

&lt;p&gt;Rather than designing jobs on the assumption of perfection, &lt;strong&gt;assume imperfect execution and layer detection and recovery on top&lt;/strong&gt;—since moving to that philosophy, accidents like a four-lane simultaneous stall haven't happened.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfalls
&lt;/h2&gt;

&lt;p&gt;Listed in the order I actually hit them. For every one, I thought "I'll notice eventually"—and didn't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Omitting &lt;code&gt;--model&lt;/code&gt; doesn't make the command error&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;claude -p&lt;/code&gt; runs silently even with incomplete arguments. When omitted, it shares the interactive session's quota, so the problem surfaces "when the interactive quota runs out," not the moment you write the batch. It doesn't break the day you forget it; it dies quietly weeks later at some unrelated moment. The farther apart cause and effect are, the harder it is to spot the cause.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A limit message comes back with exit 0&lt;/strong&gt;&lt;br&gt;
When &lt;code&gt;--model&lt;/code&gt; is omitted and the interactive quota runs out, &lt;code&gt;claude -p&lt;/code&gt; writes "You've reached your Fable limit." to stdout and exits. There are cases where the exit code is 0 here. A script that judges success by &lt;code&gt;$?&lt;/code&gt; can't distinguish the limit message from normal output. &lt;code&gt;[ -s output.txt ]&lt;/code&gt; passes too, as long as the file isn't empty. Even 32 characters counts as "there was output."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The stock counter and the real file count drift apart&lt;/strong&gt;&lt;br&gt;
In the &lt;code&gt;gen-column.mjs&lt;/code&gt; case, every time the job "completed" with exit 0 the success counter went up, and stock "appeared to be increasing." In reality, not one was added. A counter is a declaration that "I tried to generate," not evidence that "I generated." The reason it took three weeks to notice, until stock was actually at 2, was that the monitoring metric was the counter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Four lanes stopping at once gets misread as "automation is working"&lt;/strong&gt;&lt;br&gt;
If one job stops, it's easy to notice "something's off." When four go silent at once, the unease of "everything's too quiet" fades. If anything, it pulls you toward "my hands are free = automation is working." The four-lane stall that surfaced on 9/3 was missed for three weeks by exactly this mechanism.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Vendors change quota wording without notice&lt;/strong&gt;&lt;br&gt;
The 9/2 error came in the form "You've hit your session limit · resets 6:50pm (Asia/Tokyo)," phrased differently from the earlier "weekly limit" and "Fable limit." &lt;code&gt;QUOTA_PATTERNS&lt;/code&gt; at the time didn't include this pattern, and it slipped past quota detection. The result was 306 lines of wasted Claude calls over 4 hours. Write detection as "exact match of the wording" and it's neutralized the instant the vendor changes the phrasing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The circuit breaker stops posting jobs uniformly&lt;/strong&gt;&lt;br&gt;
This was the 8/21 incident. When generation jobs exhausted the quota, posting jobs stopped as collateral. Measured, &lt;code&gt;xpilot.autopost&lt;/code&gt;'s execution rate had dropped to 43%. Generation jobs can retry if they fail. A post that misses its window can never be filled in. Stopping jobs with fundamentally different priorities under the same circuit breaker was the design flaw.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;launchd doesn't go through a login shell&lt;/strong&gt;&lt;br&gt;
A script started from a plist runs in a different environment than when you run it manually from the terminal. The PATH in &lt;code&gt;~/.zshrc&lt;/code&gt; isn't read. &lt;code&gt;node&lt;/code&gt; and &lt;code&gt;claude&lt;/code&gt; commands managed by nvm can't be found. That's why com.shun.article-daily.plist explicitly sets PATH under &lt;code&gt;EnvironmentVariables&lt;/code&gt;. This is the cause of the vast majority of "works locally, dies under launchd."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;.mcp.json&lt;/code&gt; gets read and the batch hits external networks&lt;/strong&gt;&lt;br&gt;
Under launchd, the &lt;code&gt;.mcp.json&lt;/code&gt; in the home directory is read automatically. If there's MCP server configuration for local development, the batch goes out to connect externally without meaning to. Without &lt;code&gt;--strict-mcp-config --mcp-config '{"mcpServers":{}}'&lt;/code&gt;, a batch can do things you can't reproduce locally.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hardcoding model IDs in scripts causes mass breakage&lt;/strong&gt;&lt;br&gt;
When the vendor retires or renames a model alias, you end up rewriting every script with the string embedded. Write it with the version baked in, like &lt;code&gt;--model claude-sonnet-4-5&lt;/code&gt;, and every model update means a tour through all your scripts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The "alert when stock drops below 5" never fired&lt;/strong&gt;&lt;br&gt;
Because the alert logic pulled the stock count from the counter, the alert didn't fire while the counter was "increasing" (it actually wasn't). The monitoring metric has to be the real file count on the filesystem, not the generation job's success count.&lt;/p&gt;


&lt;h2&gt;
  
  
  Best Practices
&lt;/h2&gt;

&lt;p&gt;Distilled from the four-lane simultaneous stall and three weeks of unnoticed loss, these are the rules I still apply to every job.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Always specify &lt;code&gt;--model&lt;/code&gt; on &lt;code&gt;claude -p&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
This is the starting point for everything. Omission is forbidden. Before writing a job, make it a rule that "a &lt;code&gt;claude -p&lt;/code&gt; without &lt;code&gt;--model&lt;/code&gt; does not exist."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Write it with an environment-variable fallback&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nt"&gt;--model&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ARTICLE_MODEL&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;sonnet&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Don't write model IDs directly into scripts. The &lt;code&gt;:-sonnet&lt;/code&gt; fallback for the undefined case prevents "forgot it and fell to the default quota," and one line—&lt;code&gt;launchctl setenv ARTICLE_MODEL haiku&lt;/code&gt;—switches every job.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Put a postcondition gate immediately after claude -p&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;MIN_ARTICLE_BYTES&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1200
article_ok&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;return &lt;/span&gt;1
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;stat&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt;%z &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-ge&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$MIN_ARTICLE_BYTES&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;return &lt;/span&gt;1
  &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-qE&lt;/span&gt; &lt;span class="s1"&gt;'^title:'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;return &lt;/span&gt;1
  &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-qiE&lt;/span&gt; &lt;span class="s1"&gt;'request timed out|TODO: *本文'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;return &lt;/span&gt;1
  &lt;span class="k"&gt;return &lt;/span&gt;0
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"There was output" is not success. Minimum byte count, presence of frontmatter, no timeout phrasing mixed in—only when all three conditions are met is it recorded as "generation succeeded."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Monitor stock by real file count&lt;/strong&gt;&lt;br&gt;
A counter is intent; the real file count is evidence. Base the alert metric on the actual number from &lt;code&gt;ls ~/.claude/stock/ | wc -l&lt;/code&gt;. If the counter goes up but the files don't, it's a failure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Write quota detection on the "structure of the wording"&lt;/strong&gt;&lt;br&gt;
Instead of an exact match like &lt;code&gt;"Fable limit"&lt;/code&gt;, use structural patterns like these.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(?:you(?:&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ve| have) )?hit your (?:weekly |5-?hour |usage |session )?limit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;resets?\s+(?:at\s+)?\d{1,2}(?::\d{2})?\s*[ap]m&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Even if the vendor changes the wording, any phrasing with the structure of "you've hit a limit" gets caught.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Judge by both exit code and output content&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;is_quota_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;returncode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;returncode&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;quota_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="c1"&gt;# ゆるいパターン
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;strict_quota_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;     &lt;span class="c1"&gt;# 厳しいパターン（400字以下＋構造マッチ）
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To prevent exit 0 false successes, check the exit code and stdout independently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;7. Use a 400-character cap to separate quota messages from article bodies&lt;/strong&gt;&lt;br&gt;
The message Claude returns at a limit is a few dozen characters. A normal article body is almost never 400 characters or less. If &lt;code&gt;len(trimmed) &amp;gt; 400&lt;/code&gt;, don't treat it as a limit message—this one line prevents the false positive where "an article about quota trips my own circuit breaker."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;8. Put &lt;code&gt;--strict-mcp-config --mcp-config '{"mcpServers":{}}'&lt;/code&gt; into batches as a pair&lt;/strong&gt;&lt;br&gt;
Under launchd, &lt;code&gt;.mcp.json&lt;/code&gt; gets read. Explicitly passing an empty server config shuts down the accident of a batch unintentionally calling external MCP. These two flags are one setting together. One alone doesn't work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;9. Change circuit-breaker behavior by job priority&lt;/strong&gt;&lt;br&gt;
Don't stop posting jobs and generation jobs with the same circuit breaker. Mark posts with &lt;code&gt;priority: true&lt;/code&gt; and allow one attempt per &lt;code&gt;PRIORITY_PROBE_INTERVAL&lt;/code&gt; (every 30 minutes) even while the circuit is open. A successful attempt detects quota recovery and closes the circuit—one mechanism achieves both keeping posts alive and detecting recovery early.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;10. Run the grep gate the moment you write a plist&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"claude -p&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;claude --print"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  ~/.claude/scripts/ ~/dev/ &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.sh"&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.mjs"&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.js"&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.py"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s1"&gt;'--model'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s1"&gt;'#.*claude -p'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero lines means every job is safe. Even one line back means something's missing. Always run it when adding a plist. Pairing it with the weekly &lt;code&gt;launchctl list&lt;/code&gt; check turns it into a habit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;11. The three-piece set: &lt;code&gt;ProcessType: Background&lt;/code&gt; / &lt;code&gt;LowPriorityIO: true&lt;/code&gt; / &lt;code&gt;Nice: 10&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
Put this combination into every batch job. By explicitly telling the OS "low-priority background task," it won't interfere with foreground work like having Figma open or researching in a browser. The plist default sets nothing (Standard priority), so if you don't specify, your foreground gets squeezed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;12. Set the launchd PATH explicitly via EnvironmentVariables&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;node&lt;/code&gt; and &lt;code&gt;claude&lt;/code&gt; managed by nvm exist only in the login shell's PATH. Since launchd launches don't go through a login shell, specify a PATH containing nvm's binary path under the EnvironmentVariables key. In the com.shun.article-daily.plist implementation, &lt;code&gt;~/.nvm/versions/node/v24.13.0/bin&lt;/code&gt; goes at the front of PATH.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;13. For multi-level calls, grep recursively down to the leaf&lt;/strong&gt;&lt;br&gt;
In the chain plist → quota-guard.py → run-and-notify.sh → article-daily-stock.sh → &lt;code&gt;claude -p&lt;/code&gt;, applying the grep gate only to the plist never inspects the leaf script. Specify &lt;code&gt;.sh .mjs .js .py&lt;/code&gt; via &lt;code&gt;--include&lt;/code&gt; and search recursively to the end of the call chain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;14. Leave dated incident comments in the script&lt;/strong&gt;&lt;br&gt;
Line 37 of claude-quota-guard.py still has the comment &lt;code&gt;# 2026-09-02: 5時間上限は〜の形式で返る&lt;/code&gt;. Writing down why a pattern was added and when means that six months later, you (or Claude Code) don't have to guess "why is this pattern here" when reading the code. Incident history gets read more when it sits next to the code than in a commit message.&lt;/p&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;Omitting &lt;code&gt;--model&lt;/code&gt; is a one-line oversight. It produced an outage that lasted three weeks, hit four lanes simultaneously, and drained stock down to 2. The command doesn't error, it returns exit 0, and the log says "there was 1 line"—when those three overlap, a human eye won't catch it for weeks.&lt;/p&gt;

&lt;p&gt;To use automation as infrastructure, you can't design jobs on the premise that they "work perfectly." The right philosophy is to &lt;strong&gt;assume imperfect execution and layer detection and recovery on top.&lt;/strong&gt; Explicit &lt;code&gt;--model&lt;/code&gt; is the starting point; the postcondition gate, real-file-count monitoring, structural pattern detection, and the priority-aware circuit breaker are its extensions.&lt;/p&gt;

&lt;p&gt;Today, accidents where four lanes go silent at once no longer happen. The same principles apply as jobs grow, so even with 171 jobs running, the added mental cost is close to zero. "The environment works in my place" only holds because there's a mechanism in place for it to notice when it breaks and recover on its own.&lt;/p&gt;

&lt;p&gt;Have you ever had a scheduled job that "succeeded" every single day while producing nothing—and what was the check that finally caught it?&lt;/p&gt;




&lt;p&gt;The full picture of the system, the breakdown of the ¥1.2M/month, and the 30-day procedure are compiled in a paid note.&lt;br&gt;
📕 &lt;a href="https://note.com/bokuwalily/n/n849b3a07784a" rel="noopener noreferrer"&gt;How to actually make money with an autonomous Claude Code environment — system, real examples, getting started, and support&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by **Lily&lt;/em&gt;* — I ship iOS apps and automate my content stack with Claude Code.&lt;br&gt;
Follow along: &lt;a href="https://bokuwalily.com" rel="noopener noreferrer"&gt;Portfolio&lt;/a&gt; · &lt;a href="https://x.com/bokuwalily" rel="noopener noreferrer"&gt;X&lt;/a&gt; · &lt;a href="https://github.com/bokuwalily" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;*&lt;/p&gt;

</description>
      <category>automation</category>
      <category>claudecode</category>
      <category>devops</category>
      <category>bash</category>
    </item>
    <item>
      <title>5 Pitfalls I Hit Building a Daemon That Auto-Approves Claude Code's Computer Use Dialog</title>
      <dc:creator>Lily</dc:creator>
      <pubDate>Sat, 19 Sep 2026 00:00:05 +0000</pubDate>
      <link>https://dev.to/bokuwalily/5-pitfalls-i-hit-building-a-daemon-that-auto-approves-claude-codes-computer-use-dialog-5amk</link>
      <guid>https://dev.to/bokuwalily/5-pitfalls-i-hit-building-a-daemon-that-auto-approves-claude-codes-computer-use-dialog-5amk</guid>
      <description>&lt;p&gt;Last time I wrote about &lt;a href="https://dev.to/bokuwalily/wi-fi-was-dead-for-5-hours-while-chrome-kept-working-4960-dns-failures-and-the-5-minute-probe-3gp5"&gt;the morning my Wi-Fi was dead for five hours&lt;/a&gt;. This post is in the same "stop babysitting an unattended machine" vein: a small daemon that &lt;strong&gt;presses Enter on Claude Code's Computer Use approval dialog for you&lt;/strong&gt;. My first attempt, launched from launchd, produced nothing but nine &lt;code&gt;osascript timeout&lt;/code&gt; lines at 22-second intervals and never sent a single keystroke. Once I moved it to run as a child of Terminal, it went from zero approvals to actually delivering &lt;code&gt;\r&lt;/code&gt; to the dialog within seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: pressing "Allow for this session" by hand, every time
&lt;/h2&gt;

&lt;p&gt;When you use Computer Use (screen control) in Claude Code, a confirmation dialog appears: &lt;code&gt;Computer Use wants to control these apps&lt;/code&gt;. Selecting "Allow for this session" and pressing Enter gets you through, but it &lt;strong&gt;comes back every single time you cross a session boundary&lt;/strong&gt;. I wanted to know whether it could be made permanent via settings, so I ran &lt;code&gt;strings&lt;/code&gt; on the binary to check.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;computerUseMcpState.allowedApps（セッション内メモリ）にしか積まれず初期値は空
settings.json / ~/.claude.json に事前付与キーは無い
dialog kind=computer_use_approval（requestDialog系）で PermissionRequest hookも通らない
bypassPermissionsModeAccepted: true でも出る
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's my field log from tracing the &lt;code&gt;claude 2.1.266&lt;/code&gt; binary with &lt;code&gt;strings&lt;/code&gt;. The approval state lives only in session memory, in &lt;code&gt;computerUseMcpState.allowedApps&lt;/code&gt;, and vanishes the moment you cross a process boundary. The same investigation did turn up settings that &lt;em&gt;can&lt;/em&gt; be made permanent: &lt;code&gt;bypassPermissionsModeAccepted: true&lt;/code&gt; in &lt;code&gt;~/.claude.json&lt;/code&gt; and &lt;code&gt;projects[*].hasTrustDialogAccepted: true&lt;/code&gt; (I set 85 of them in one pass) both work, but they only suppress the "dangerous operation" confirmation, which is a different thing from the Computer Use approval dialog. In other words, &lt;strong&gt;there's no room to fix this on the settings side&lt;/strong&gt;. That was the conclusion &lt;code&gt;strings&lt;/code&gt; nailed down.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; If you put a &lt;code&gt;*&lt;/code&gt; in the middle of an &lt;code&gt;allow&lt;/code&gt; rule string (for example grep's &lt;code&gt;[^}]*&lt;/code&gt; regex), you get a "wildcard before the rest" warning and the rule is rejected. Putting the wildcard at the end is the safe option.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If settings won't do it, the only option left is to look at the screen and press Enter on the human's behalf. So I wrote &lt;code&gt;~/.claude/scripts/cu_dialog_autoallow.py&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation: poll every Terminal tab, and don't misfire
&lt;/h2&gt;

&lt;p&gt;What it does is simple: poll every tab in Apple Terminal every 2 seconds, and when the dialog string shows up, send &lt;code&gt;\r&lt;/code&gt;. But "see it, press Enter immediately" causes accidents. The implementation leans heavily toward misfire prevention.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;READ&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'''&lt;/span&gt;&lt;span class="s"&gt;
tell application &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Terminal&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;
  set out to &lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="s"&gt;
  repeat with w in windows
    set k to count of tabs of w
    repeat with i from 1 to k
      set c to contents of tab i of w
      set out to out &amp;amp; (tty of tab i of w) &amp;amp; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;SEP&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; &amp;amp; c &amp;amp; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;SEP&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;
    end repeat
  end repeat
  return out
end tell
&lt;/span&gt;&lt;span class="sh"&gt;'''&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;history&lt;/code&gt; property is heavy, around 600KB per tab, so I read &lt;strong&gt;only the visible screen (&lt;code&gt;contents of tab i of w&lt;/code&gt;)&lt;/strong&gt;. There's also a trap: if you iterate with &lt;code&gt;repeat with t in tabs&lt;/code&gt; and try to grab &lt;code&gt;contents of t&lt;/code&gt;, you get the tab object itself back. You have to index explicitly in the form &lt;code&gt;tab i of w&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The core of misfire prevention is checking which option the cursor is pointing at before firing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;selected_is_allow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# the highlighted option line starts with the pointer glyph
&lt;/span&gt;    &lt;span class="n"&gt;tail&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Enter to confirm&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;lines&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;tail&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;splitlines&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;reversed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lines&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;:]):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;❯&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Allow for this session&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The line starting with &lt;code&gt;❯&lt;/code&gt; (or &lt;code&gt;&amp;gt;&lt;/code&gt; for ASCII-only environments) is the pointer line, and the only thing checked is whether it contains "Allow for this session". If the dialog's default cursor is on the Deny side and you send Enter anyway, you get a denial, so skipping this step makes things actively worse.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Computer Use wants to control these apps&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Enter to confirm&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;last_sent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tty&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;selected_is_allow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;tty&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: dialog visible but cursor not on Allow; skipping&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;last_sent&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;tty&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="n"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;press_enter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tty&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The way Enter is sent is also designed so it doesn't steal focus.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;press_enter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tty&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;scpt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'''&lt;/span&gt;&lt;span class="s"&gt;
tell application &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Terminal&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;
  repeat with w in windows
    repeat with t in tabs of w
      if tty of t is &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;tty&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; then
        do script &lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="s"&gt; in t
        return &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;
      end if
    end repeat
  end repeat
  return &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;notfound&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;
end tell&lt;/span&gt;&lt;span class="sh"&gt;'''&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;do script "" in t&lt;/code&gt; just writes a single newline byte to that tab's tty; it doesn't activate the window. Sends are deduplicated per tty with an 8-second window, and &lt;code&gt;fcntl.flock&lt;/code&gt; prevents multiple instances from running.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap I fell into: launching from launchd hangs silently
&lt;/h2&gt;

&lt;p&gt;My first attempt was to launch it from a recurring launchd job. The result: AppleEvents from &lt;code&gt;python3&lt;/code&gt; to &lt;code&gt;Terminal&lt;/code&gt; &lt;strong&gt;hang silently under TCC (Automation)&lt;/strong&gt;. No prompt, nothing. It just freezes. Here's the actual log.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;2026-09-12 15:32:35 osascript timeout
2026-09-12 15:32:57 osascript timeout
2026-09-12 15:33:19 osascript timeout
2026-09-12 15:33:41 osascript timeout
2026-09-12 15:34:03 osascript timeout
2026-09-12 15:34:25 osascript timeout
2026-09-12 15:34:47 osascript timeout
2026-09-12 15:35:09 osascript timeout
2026-09-12 15:35:31 osascript timeout
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can see &lt;code&gt;osascript timeout&lt;/code&gt; repeating endlessly at 22-second intervals. A process launched from launchd sits outside the user's TCC context, so its control request to Terminal can't even surface an approval dialog and gets swallowed whole.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; TCC (Automation) is nastier than "a permission dialog appears and gets denied": it &lt;strong&gt;hangs silently without ever showing a prompt&lt;/strong&gt;. If you're sending AppleEvents via launchd, suspecting this failure mode is the fastest route to a fix.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The workaround: launch as a child process of Terminal
&lt;/h2&gt;

&lt;p&gt;The workaround was to &lt;strong&gt;launch it as a child process of Terminal itself&lt;/strong&gt;. AppleEvents from a child process of the same app don't trigger a TCC permission request at all. Here's what's at the end of my &lt;code&gt;~/.zshrc&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Claude Code computer-use「Allow for this session」を自動Enter（Terminal起点で起動=TCC不要・launchd起点はTCCで固まる）&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TERM_PROGRAM&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"Apple_Terminal"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; pgrep &lt;span class="nt"&gt;-qf&lt;/span&gt; cu_dialog_autoallow.py&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
  &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;nohup&lt;/span&gt; /usr/bin/python3 ~/.claude/scripts/cu_dialog_autoallow.py &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1 &amp;amp;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;TERM_PROGRAM&lt;/code&gt; restricts this to shells started from Apple Terminal, &lt;code&gt;pgrep&lt;/code&gt; stops a second instance, and &lt;code&gt;nohup&lt;/code&gt; keeps it resident in the background. This block runs every time you open a new tab, but thanks to the &lt;code&gt;pgrep&lt;/code&gt; guard only one process ever actually starts. After the switch, &lt;code&gt;start pid=...&lt;/code&gt; shows up in the log and &lt;code&gt;\r&lt;/code&gt; is actually being delivered to the dialog.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;2026-09-12 15:36:16 /dev/ttys010: CU dialog -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;Enter &lt;span class="o"&gt;(&lt;/span&gt;sent&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="gp"&gt;2026-09-12 15:36:18 /dev/ttys011: CU dialog -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;Enter &lt;span class="o"&gt;(&lt;/span&gt;sent&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="gp"&gt;2026-09-12 15:36:26 /dev/ttys010: CU dialog -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;Enter &lt;span class="o"&gt;(&lt;/span&gt;sent&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Pitfalls I hit
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Launched from launchd, AppleEvents hang silently under TCC&lt;/strong&gt; → launch as a child of Terminal via &lt;code&gt;nohup&lt;/code&gt; at the end of &lt;code&gt;.zshrc&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;history&lt;/code&gt; property is heavy at 600KB per tab&lt;/strong&gt; → read only the visible screen with &lt;code&gt;contents of tab i of w&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;repeat with t in tabs; contents of t&lt;/code&gt; returns the tab itself&lt;/strong&gt; → use explicit index access with &lt;code&gt;tab i of w&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sending Enter without checking the cursor position misfires (you press it on the Deny side too)&lt;/strong&gt; → parse the &lt;code&gt;❯&lt;/code&gt; pointer line and send only when Allow is confirmed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transient AppleScript connection drops and syntax errors show up in the log&lt;/strong&gt; (&lt;code&gt;実行エラー: 接続が無効です (-609)&lt;/code&gt; / &lt;code&gt;syntax error ... (-2741)&lt;/code&gt;) → design the loop to ignore one-off timeouts and keep polling (a lost cycle is retried 2 seconds later)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last point is backed by real logs. On the morning of 09-13 and around midday on 09-16, connection drops and syntax errors came in bursts, but every one of them recovered on its own in the restart cycle a few minutes later, and an actual dialog approval (&lt;code&gt;CU dialog -&amp;gt; Enter (sent)&lt;/code&gt;) was also recorded at 23:30 on 09-16. One caveat: I haven't yet measured &lt;code&gt;\r&lt;/code&gt; reaching a real dialog at the moment another session is holding the CU lock; the end-to-end confirmation so far is against a fake dialog screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Per the &lt;code&gt;strings&lt;/code&gt; investigation, &lt;strong&gt;there is no settings-based route to make Computer Use approval permanent&lt;/strong&gt; (session memory only; the PermissionRequest hook doesn't fire either)&lt;/li&gt;
&lt;li&gt;The workaround is a design that &lt;strong&gt;polls the visible screen and checks the cursor position&lt;/strong&gt; to press Enter on your behalf without misfiring&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Launching from launchd hangs silently under TCC.&lt;/strong&gt; Running as a child process of Terminal is the only workaround&lt;/li&gt;
&lt;li&gt;One-off connection drops and syntax errors are swallowed by the polling loop and self-heal&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next time I'll write about a sibling of this AppleScript watcher daemon: &lt;a href="https://dev.to/bokuwalily/snap-8-windows-into-a-grid-on-an-ultrawide-in-one-command-a-swift-applescript-convergence-loop-4oha"&gt;tiling Terminal windows in one shot&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Have you found any other way to get past a TCC-gated AppleEvent from a background process without going through the app itself?&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by **Lily&lt;/em&gt;* — I ship iOS apps and automate my content stack with Claude Code.&lt;br&gt;
Follow along: &lt;a href="https://bokuwalily.com" rel="noopener noreferrer"&gt;Portfolio&lt;/a&gt; · &lt;a href="https://x.com/bokuwalily" rel="noopener noreferrer"&gt;X&lt;/a&gt; · &lt;a href="https://github.com/bokuwalily" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;*&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>applescript</category>
      <category>macos</category>
      <category>automation</category>
    </item>
    <item>
      <title>4 Automation Chrome Processes Were Killing My Real Browser: Bundle-ID Isolation and a 6-Hour Self-Repair Loop</title>
      <dc:creator>Lily</dc:creator>
      <pubDate>Fri, 18 Sep 2026 11:00:06 +0000</pubDate>
      <link>https://dev.to/bokuwalily/4-automation-chrome-processes-were-killing-my-real-browser-bundle-id-isolation-and-a-6-hour-41eg</link>
      <guid>https://dev.to/bokuwalily/4-automation-chrome-processes-were-killing-my-real-browser-bundle-id-isolation-and-a-6-hour-41eg</guid>
      <description>&lt;p&gt;At 13:31 JST on September 1, 2026, &lt;code&gt;open -a "Google Chrome"&lt;/code&gt; returned error code &lt;code&gt;-600&lt;/code&gt; and nothing happened. Three headless Chrome processes and one headful one were already running on my Mac — all spawned by the same automation that had grown from ¥0 to ¥1.2M a month in six months. That afternoon, the whole setup got redesigned from the ground up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Setup Works
&lt;/h2&gt;

&lt;p&gt;Once a solo developer's automation grows past a certain size, there's a problem you will inevitably hit: "the jobs are running fine, but at some point my own working environment broke."&lt;/p&gt;

&lt;p&gt;On the morning of September 1, 2026, Lily's Mac had three headless Chrome processes and one headful one — four in total — running at the same time. Auto-liking on social media, thumbnail generation, per-account profile management: each was an independent job calling Playwright. And every one of them was directly exec-ing &lt;code&gt;/Applications/Google Chrome.app/Contents/MacOS/Google Chrome&lt;/code&gt;. That was all it took.&lt;/p&gt;

&lt;p&gt;macOS LaunchServices &lt;strong&gt;treats apps with the same CFBundleIdentifier as a single instance of the same app.&lt;/strong&gt; Google Chrome's bundle ID is &lt;code&gt;com.google.Chrome&lt;/code&gt;. If an automation job has already started a process with that bundle ID, then when you run &lt;code&gt;open -a "Google Chrome"&lt;/code&gt; from the Dock or Spotlight, the OS decides "that app is already running, so I'll just bring the existing frontmost window forward." But the automation process usually has no window. The Chrome window a human expects never comes back. That is what &lt;code&gt;-600 (procNotFound)&lt;/code&gt; really is.&lt;/p&gt;

&lt;p&gt;The nasty part is that &lt;strong&gt;you can't notice this until it happens.&lt;/strong&gt; The automation jobs are running normally. Exit code is 0. Logs are clean. Only when a human tries to use Chrome does nothing happen.&lt;/p&gt;




&lt;p&gt;In general, this problem has the property that "the more automation jobs you add, the higher the probability." With one job, Chrome is held for a short time. As you go to three, five, ten jobs, Chrome being permanently held by one job or another becomes the normal state. The more automation you stack on to grow revenue, the longer your own Chrome is unusable. The causality is inverted.&lt;/p&gt;

&lt;p&gt;There were two possible directions for a fix. One: make every automation job kill Chrome when it's done. Two: separate the automation browser from the human's Chrome &lt;strong&gt;at the level of its name.&lt;/strong&gt; The former requires managing the shutdown timing of every job, and each new job creates a new gap. The latter, once built, eliminates the interference at the OS level. Only the latter is a permanent fix.&lt;/p&gt;

&lt;p&gt;Create a separate bundle named &lt;code&gt;/Applications/Chrome Automation.app&lt;/code&gt; and change its bundle ID to &lt;code&gt;com.google.ChromeAutomation&lt;/code&gt;. From the OS's perspective it's a completely different app. LaunchServices manages &lt;code&gt;com.google.Chrome&lt;/code&gt; and &lt;code&gt;com.google.ChromeAutomation&lt;/code&gt; separately. No matter how many Chrome Automation instances the automation launches, a human's &lt;code&gt;open -a "Google Chrome"&lt;/code&gt; looks for a different bundle ID and is unaffected.&lt;/p&gt;

&lt;p&gt;If I had to explain in one line why this works: "&lt;strong&gt;Resource contention disappears the moment you separate the names.&lt;/strong&gt;" As long as they share a name, you have a structure where killing one kills the other. Split the name, and the OS isolates them for you.&lt;/p&gt;




&lt;p&gt;There is, however, one awkward implementation wall.&lt;/p&gt;

&lt;p&gt;Playwright's &lt;code&gt;channel: 'chrome'&lt;/code&gt; option writes the path of the browser it launches into an internal file, &lt;code&gt;playwright-core/lib/coreBundle.js&lt;/code&gt;. This file lives inside &lt;code&gt;node_modules&lt;/code&gt;. Every time &lt;code&gt;npm install&lt;/code&gt; runs, it gets overwritten and reverts to &lt;code&gt;/Applications/Google Chrome.app&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In other words, even if you rewrite the path, it periodically reverts. Designing around the fact that "it reverts" is the key to stable operation. "Anticipate the revert and re-apply periodically." That's the reason the 6-hour self-repair script exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Overall Flow
&lt;/h2&gt;

&lt;p&gt;Here's the system architecture as a diagram.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  ┌──────────────────────────────────────────────────────────┐
  │  npm install（いつ走るかわからない）                          │
  │     ↓                                                     │
  │  playwright-core/lib/coreBundle.js                        │
  │     "...Google Chrome.app/Contents/MacOS/Google Chrome"  │ ← 元に戻る
  └──────────────────────────────────────────────────────────┘
                    ↑最大6時間以内に検知
  ┌──────────────────────────────────────────────────────────┐
  │  launchd  com.shun.chrome-automation-repair              │
  │  StartInterval: 21600（6時間ごと）RunAtLoad: true          │
  │     ↓                                                     │
  │  ~/.claude/scripts/chrome-automation-repair.sh           │
  │                                                           │
  │  1. keychain_gate() ─ --use-mock-keychain 未設定を警告      │
  │                                                           │
  │  2. バージョン比較                                           │
  │     src_ver（Google Chrome.app）                           │
  │     dst_ver（Chrome Automation.app）                      │
  │        一致 → スキップ                                      │
  │        不一致 or 不在 → rebuild                            │
  │            cp -R /Applications/Google Chrome.app         │
  │                   /Applications/Chrome Automation.app    │
  │            PlistBuddy: CFBundleIdentifier                │
  │                        → com.google.ChromeAutomation     │
  │            PlistBuddy: CFBundleName                      │
  │                        → Chrome Automation               │
  │            codesign --force --deep --sign -              │ ← ad-hoc 再署名
  │                                                           │
  │  3. playwright-core を glob で検索（find より速い）           │
  │     ~/dev/*/node_modules/playwright-core/lib/coreBundle.js│
  │     ~/content/*/node_modules/playwright-core/...         │
  │     ※ネスト4階層まで対応                                     │
  │        sed: Google Chrome.app のパス → AUTO_BIN に置換      │
  │        patched=N  already=M                               │
  │        patched+already==0 → exit 1（無音成功を防ぐ）          │
  │                                                           │
  │  ログ: ~/.claude/logs/chrome-automation-repair.log        │
  └──────────────────────────────────────────────────────────┘
                    ↓ パッチ済み
  ┌──────────────────────────────────────────────────────────┐
  │  playwright channel:'chrome' の解決先                      │
  │  /Applications/Chrome Automation.app/                    │
  │              Contents/MacOS/Google Chrome                │ ← 自動化バンドル
  └──────────────────────────────────────────────────────────┘
         ↕ 完全に独立
  ┌──────────────────────────────────────────────────────────┐
  │  open -a "Google Chrome"                                 │
  │  com.google.Chrome ← 人間の Chrome（干渉なし）               │
  └──────────────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let's look at the core parts of the script in real code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bundle Creation and Version Management
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;SRC&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/Applications/Google Chrome.app"&lt;/span&gt;
&lt;span class="nv"&gt;DST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/Applications/Chrome Automation.app"&lt;/span&gt;
&lt;span class="nv"&gt;BUNDLE_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"com.google.ChromeAutomation"&lt;/span&gt;
&lt;span class="nv"&gt;AUTO_BIN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DST&lt;/span&gt;&lt;span class="s2"&gt;/Contents/MacOS/Google Chrome"&lt;/span&gt;

ver&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; /usr/libexec/PlistBuddy &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"Print :CFBundleShortVersionString"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
        &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;/Contents/Info.plist"&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="nv"&gt;src_ver&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;ver &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SRC&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nv"&gt;dst_ver&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;ver &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DST&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DST&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$src_ver&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$dst_ver&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SRC&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DST&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  /usr/libexec/PlistBuddy &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"Set :CFBundleIdentifier &lt;/span&gt;&lt;span class="nv"&gt;$BUNDLE_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
      &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DST&lt;/span&gt;&lt;span class="s2"&gt;/Contents/Info.plist"&lt;/span&gt;
  /usr/libexec/PlistBuddy &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"Set :CFBundleName Chrome Automation"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
      &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DST&lt;/span&gt;&lt;span class="s2"&gt;/Contents/Info.plist"&lt;/span&gt;
  codesign &lt;span class="nt"&gt;--force&lt;/span&gt; &lt;span class="nt"&gt;--deep&lt;/span&gt; &lt;span class="nt"&gt;--sign&lt;/span&gt; - &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DST&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;ver()&lt;/code&gt; function reads &lt;code&gt;CFBundleShortVersionString&lt;/code&gt; (e.g. &lt;code&gt;127.0.6533.120&lt;/code&gt;) via &lt;code&gt;PlistBuddy&lt;/code&gt;. When Google Chrome auto-updates, &lt;code&gt;src_ver&lt;/code&gt; and &lt;code&gt;dst_ver&lt;/code&gt; drift apart, so the next 6-hour cycle automatically rebuilds Chrome Automation.app.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;-&lt;/code&gt; (minus) in &lt;code&gt;codesign --force --deep --sign -&lt;/code&gt; means an &lt;strong&gt;ad-hoc signature&lt;/strong&gt;. The moment you rewrite the bundle ID in &lt;code&gt;Info.plist&lt;/code&gt;, Google's signature becomes invalid. If you try to launch without re-signing, the helper processes die instantly with &lt;code&gt;SIGKILL&lt;/code&gt; (exit 137). An ad-hoc signature is a self-signature with no trust chain, but for local-only execution that's fine.&lt;/p&gt;

&lt;h3&gt;
  
  
  Glob Search and Patching of playwright-core
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;SCAN_ROOTS&lt;/span&gt;&lt;span class="o"&gt;=(&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;HOME&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/dev"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;HOME&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/content"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;

list_core_bundles&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;root sub
  &lt;span class="nb"&gt;shopt&lt;/span&gt; &lt;span class="nt"&gt;-s&lt;/span&gt; nullglob
  &lt;span class="k"&gt;for &lt;/span&gt;root &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;SCAN_ROOTS&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
    for &lt;/span&gt;sub &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
      &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$root&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;/&lt;span class="k"&gt;*&lt;/span&gt;/node_modules/playwright-core/lib/coreBundle.js &lt;span class="se"&gt;\&lt;/span&gt;
      &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$root&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;/&lt;span class="k"&gt;*&lt;/span&gt;/node_modules/playwright/node_modules/playwright-core/lib/coreBundle.js &lt;span class="se"&gt;\&lt;/span&gt;
      &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$root&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;/&lt;span class="k"&gt;*&lt;/span&gt;/&lt;span class="k"&gt;*&lt;/span&gt;/node_modules/playwright-core/lib/coreBundle.js &lt;span class="se"&gt;\&lt;/span&gt;
      &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$root&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;/&lt;span class="k"&gt;*&lt;/span&gt;/&lt;span class="k"&gt;*&lt;/span&gt;/node_modules/playwright/node_modules/playwright-core/lib/coreBundle.js
    &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$sub&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$sub&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;done
  done
  &lt;/span&gt;&lt;span class="nb"&gt;shopt&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; nullglob
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This sweeps the repositories under &lt;code&gt;~/dev&lt;/code&gt; and &lt;code&gt;~/content&lt;/code&gt; with glob patterns up to four levels deep. Walking the entire tree with &lt;code&gt;find . -name "coreBundle.js"&lt;/code&gt; takes 4 minutes; hitting the layouts that actually exist (the two patterns &lt;code&gt;*/node_modules/playwright-core/...&lt;/code&gt; and &lt;code&gt;*/node_modules/playwright/node_modules/playwright-core/...&lt;/code&gt;) directly with globs brings the runtime down to &lt;strong&gt;0.95 seconds&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The patch step is an in-place substitution with &lt;code&gt;sed -i ''&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;patched&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;already&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nv"&gt;IFS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; f&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  if&lt;/span&gt; /usr/bin/grep &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SRC&lt;/span&gt;&lt;span class="s2"&gt;/Contents/MacOS/Google Chrome"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
    /usr/bin/sed &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
      &lt;span class="s2"&gt;"s|&lt;/span&gt;&lt;span class="nv"&gt;$SRC&lt;/span&gt;&lt;span class="s2"&gt;/Contents/MacOS/Google Chrome|&lt;/span&gt;&lt;span class="nv"&gt;$AUTO_BIN&lt;/span&gt;&lt;span class="s2"&gt;|g"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
      &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;patched&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;patched+1&lt;span class="k"&gt;))&lt;/span&gt;
  &lt;span class="k"&gt;elif&lt;/span&gt; /usr/bin/grep &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$AUTO_BIN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nv"&gt;already&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;already+1&lt;span class="k"&gt;))&lt;/span&gt;
  &lt;span class="k"&gt;fi
done&lt;/span&gt; &amp;lt; &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;list_core_bundles&lt;span class="o"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="k"&gt;$((&lt;/span&gt;patched &lt;span class="o"&gt;+&lt;/span&gt; already&lt;span class="k"&gt;))&lt;/span&gt; &lt;span class="nt"&gt;-eq&lt;/span&gt; 0 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;log &lt;span class="s2"&gt;"ERROR: playwright-core が1件も見つからない。SCAN_ROOTS を確認せよ。"&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;patched&lt;/code&gt; is the number of files rewritten this run; &lt;code&gt;already&lt;/code&gt; is the number already patched. If both are 0, the search path is broken and nothing was found. To prevent "zero results but treated as success," it explicitly fails with &lt;code&gt;exit 1&lt;/code&gt;. This &lt;strong&gt;fail-loud design&lt;/strong&gt; prevents the later question, "why did it go six hours unfixed without anyone noticing?"&lt;/p&gt;

&lt;h3&gt;
  
  
  Scheduled Execution via launchd
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;StartInterval&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;21600&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;RunAtLoad&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;true/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;LowPriorityIO&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;true/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;Nice&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;10&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;ProcessType&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;string&amp;gt;&lt;/span&gt;Background&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;StandardOutPath&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;~/.claude/logs/chrome-automation-repair.log&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;StartInterval: 21600&lt;/code&gt; is 21600 seconds = 6 hours. With &lt;code&gt;RunAtLoad: true&lt;/code&gt;, it also runs once the moment you &lt;code&gt;launchctl load&lt;/code&gt; it. &lt;code&gt;Nice: 10&lt;/code&gt; and &lt;code&gt;LowPriorityIO: true&lt;/code&gt; lower CPU and disk priority so it doesn't interfere with human work in the background.&lt;/p&gt;

&lt;p&gt;The meaning of the 6-hour interval is an SLA: "auto-repair within at most 6 hours after an npm install runs." In practice the automation jobs rarely run &lt;code&gt;npm install&lt;/code&gt;, so in most cases the patch doesn't revert before the next cycle. Even if it does, it's fixed within 6 hours. This design accepts the "length of the repair window" as a known risk.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keychain Gate Detection
&lt;/h3&gt;

&lt;p&gt;At the top of the script there's a detection routine called &lt;code&gt;keychain_gate()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;keychain_gate&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;hits
  &lt;span class="nv"&gt;hits&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rIl&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'*.sh'&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'*.py'&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'*.js'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
      &lt;span class="nt"&gt;--exclude-dir&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;node_modules ... &lt;span class="se"&gt;\&lt;/span&gt;
      &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'Chrome Automation\.app/Contents/MacOS|Google Chrome\.app/Contents/MacOS'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
      &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;SCAN_ROOTS&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; ~/.claude/scripts 2&amp;gt;/dev/null &lt;span class="se"&gt;\&lt;/span&gt;
    | xargs &lt;span class="nt"&gt;-I&lt;/span&gt;&lt;span class="o"&gt;{}&lt;/span&gt; &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'--headless'&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="se"&gt;\&lt;/span&gt;
    | xargs &lt;span class="nt"&gt;-I&lt;/span&gt;&lt;span class="o"&gt;{}&lt;/span&gt; &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-L&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'use-mock-keychain'&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="se"&gt;\&lt;/span&gt;
    | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s1"&gt;'scent-media/scripts/ensure_chrome.sh'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$hits&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;log &lt;span class="s2"&gt;"WARNING: --use-mock-keychain 無しでChromeをheadless起動している..."&lt;/span&gt;
    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$hits&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="s1"&gt;'s/^/  /'&lt;/span&gt;
  &lt;span class="k"&gt;fi&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because Chrome Automation.app is ad-hoc signed, even if you grant "Always Allow" on the "Chrome Safe Storage" ACL in macOS Keychain, the code hash changes every time a Chrome update triggers a rebuild, and the permission is invalidated. Any script that launches headless without &lt;code&gt;--use-mock-keychain --password-store=basic&lt;/code&gt; causes a Keychain access dialog to pop up every time. Since this leads to "silent failures" in automation, there's a gate that detects and warns — but does not fix.&lt;/p&gt;

&lt;p&gt;In this round of work, the repositories fixed came to &lt;strong&gt;16 locations&lt;/strong&gt; where Playwright's &lt;code&gt;channel:'chrome'&lt;/code&gt; resolves, &lt;strong&gt;10 files&lt;/strong&gt; with direct shell and Python invocations, and &lt;strong&gt;8 repositories&lt;/strong&gt; committed. The larger the scale, the less "I fixed one place" is the end of it. I needed to propagate the fix across every repository, and have a mechanism that automatically restores the fix even when &lt;code&gt;npm install&lt;/code&gt; wipes it out. That's why this setup exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation Details
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The pgrep Guard — Don't Rebuild While Running
&lt;/h3&gt;

&lt;p&gt;The bundle-creation logic shown earlier has one more safety valve. When a version mismatch is detected, &lt;strong&gt;if Chrome Automation.app is running, skip the rebuild.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;pgrep &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"Chrome Automation.app/Contents/MacOS"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;log &lt;span class="s2"&gt;"SKIP rebuild: 自動化Chromeが稼働中 (src=&lt;/span&gt;&lt;span class="nv"&gt;$src_ver&lt;/span&gt;&lt;span class="s2"&gt; dst=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;dst_ver&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;none&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;)"&lt;/span&gt;
&lt;span class="k"&gt;else
  &lt;/span&gt;log &lt;span class="s2"&gt;"rebuild: src=&lt;/span&gt;&lt;span class="nv"&gt;$src_ver&lt;/span&gt;&lt;span class="s2"&gt; dst=&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;dst_ver&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;none&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DST&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; log &lt;span class="s2"&gt;"ERROR: 旧バンドル削除に失敗"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
  &lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SRC&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DST&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; log &lt;span class="s2"&gt;"ERROR: コピー失敗"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
  ...
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;cp -R&lt;/code&gt; takes 15–30 seconds. If Chrome Automation.app's &lt;code&gt;Contents/MacOS/Google Chrome&lt;/code&gt; is read mid-copy, a half-written binary gets executed and the whole job breaks. &lt;code&gt;pgrep -f "Chrome Automation.app/Contents/MacOS"&lt;/code&gt; looks for running processes, and if any exist, the design is to "wait until the next 6-hour cycle."&lt;/p&gt;

&lt;p&gt;Even when the rebuild is skipped, the &lt;code&gt;patched/already&lt;/code&gt; counting still runs afterward. The bundle itself may be stale, but as long as the path in &lt;code&gt;coreBundle.js&lt;/code&gt; is correct, jobs keep working.&lt;/p&gt;

&lt;h3&gt;
  
  
  launchd's Shell Doesn't Have a Human's PATH
&lt;/h3&gt;

&lt;p&gt;The plist contains an &lt;code&gt;EnvironmentVariables&lt;/code&gt; block.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;EnvironmentVariables&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;dict&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;HOME&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;string&amp;gt;&lt;/span&gt;~&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;LANG&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;string&amp;gt;&lt;/span&gt;en_US.UTF-8&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;PATH&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;~/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The shell launchd starts only inherits its environment from &lt;code&gt;/etc/launchd.conf&lt;/code&gt;; it reads neither &lt;code&gt;~/.zshrc&lt;/code&gt; nor &lt;code&gt;~/.zprofile&lt;/code&gt;. Unless you set &lt;code&gt;PATH&lt;/code&gt; explicitly, only &lt;code&gt;/usr/bin:/bin&lt;/code&gt; is available, and &lt;code&gt;codesign&lt;/code&gt; and &lt;code&gt;PlistBuddy&lt;/code&gt; in &lt;code&gt;/opt/homebrew/bin&lt;/code&gt; can't be found. &lt;code&gt;HOME&lt;/code&gt; is set explicitly so that the &lt;code&gt;${HOME}/dev&lt;/code&gt; expansion is resolved via &lt;code&gt;EnvironmentVariables&lt;/code&gt; rather than as a shell variable. &lt;code&gt;LANG&lt;/code&gt; is insurance so &lt;code&gt;grep&lt;/code&gt; doesn't mangle filenames containing Japanese.&lt;/p&gt;

&lt;p&gt;The log destinations being split into &lt;code&gt;StandardOutPath&lt;/code&gt; and &lt;code&gt;StandardErrorPath&lt;/code&gt; is for the same reason. launchd writes stdout and stderr to separate files, both landing in &lt;code&gt;~/.claude/logs/&lt;/code&gt;. If you only look at one, it can appear as though "nothing is happening."&lt;/p&gt;

&lt;h3&gt;
  
  
  Reading the keychain_gate Pipeline
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;keychain_gate()&lt;/code&gt; implementation has an unusual structure: three chained greps.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rIl&lt;/span&gt; ... &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'Chrome Automation\.app/Contents/MacOS|Google Chrome\.app/Contents/MacOS'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;SCAN_ROOTS&lt;/span&gt;&lt;span class="p"&gt;[@]&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;HOME&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/.claude/scripts"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
| xargs &lt;span class="nt"&gt;-I&lt;/span&gt;&lt;span class="o"&gt;{}&lt;/span&gt; &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'--headless'&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
| xargs &lt;span class="nt"&gt;-I&lt;/span&gt;&lt;span class="o"&gt;{}&lt;/span&gt; &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-L&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s1"&gt;'use-mock-keychain'&lt;/span&gt; &lt;span class="o"&gt;{}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Stage 1 finds "files that directly write the Chrome binary path." &lt;code&gt;node_modules&lt;/code&gt;, &lt;code&gt;.git&lt;/code&gt;, &lt;code&gt;profiles&lt;/code&gt;, &lt;code&gt;logs&lt;/code&gt;, &lt;code&gt;venv&lt;/code&gt;, and &lt;code&gt;tests&lt;/code&gt; are excluded from the search.&lt;br&gt;&lt;br&gt;
Stage 2 keeps only files containing &lt;code&gt;--headless&lt;/code&gt;. Scripts that launch with a GUI are fine even if the Keychain dialog appears.&lt;br&gt;&lt;br&gt;
Stage 3 keeps only files that do &lt;strong&gt;not&lt;/strong&gt; contain &lt;code&gt;use-mock-keychain&lt;/code&gt; (&lt;code&gt;-L&lt;/code&gt; is the "list non-matching files" flag).&lt;/p&gt;

&lt;p&gt;Files that pass all three stages are in the state of "using Chrome headless but without mock-keychain." Each time they run, the Keychain "Allow?" dialog pops up. When a dialog appears mid-automation, every process stalls until the next click. The one file excluded via &lt;code&gt;grep -v&lt;/code&gt;, &lt;code&gt;scent-media/scripts/ensure_chrome.sh&lt;/code&gt;, is deliberately designed to use the real Keychain for a different reason.&lt;/p&gt;

&lt;p&gt;The script &lt;strong&gt;does not auto-fix&lt;/strong&gt; on this detection. Persistent profiles like Instagram encrypt their session cookies with a Keychain key, and if the key changes, the login is wiped out. Log a warning and leave the decision to a human — that's the design choice.&lt;/p&gt;
&lt;h3&gt;
  
  
  Final Verification Commands
&lt;/h3&gt;

&lt;p&gt;Once the setup is in place, confirm it actually works.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. launchd の登録確認&lt;/span&gt;
launchctl list | &lt;span class="nb"&gt;grep &lt;/span&gt;chrome-automation-repair

&lt;span class="c"&gt;# 2. バンドルが生きているか&lt;/span&gt;
open &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="s2"&gt;"Chrome Automation"&lt;/span&gt; &lt;span class="nt"&gt;--args&lt;/span&gt; &lt;span class="nt"&gt;--version&lt;/span&gt;

&lt;span class="c"&gt;# 3. playwright が正しいパスを見ているか&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s2"&gt;"Chrome Automation"&lt;/span&gt; ~/dev/&lt;span class="k"&gt;*&lt;/span&gt;/node_modules/playwright-core/lib/coreBundle.js 2&amp;gt;/dev/null | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-5&lt;/span&gt;

&lt;span class="c"&gt;# 4. 人間の Chrome は独立して起動できるか&lt;/span&gt;
open &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="s2"&gt;"Google Chrome"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Number 2's &lt;code&gt;--args --version&lt;/code&gt; passes the &lt;code&gt;--version&lt;/code&gt; flag to Chrome, making it print the version string at startup (e.g. &lt;code&gt;Google Chrome 127.0.6533.120&lt;/code&gt;). If you get the string back without a window opening, the bundle is in a launchable state. Number 4 is the final confirmation: if the human's Chrome opens without returning &lt;code&gt;-600&lt;/code&gt;, the isolation is working.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where I Got Stuck
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;find&lt;/code&gt; Was Taking 4 Minutes
&lt;/h3&gt;

&lt;p&gt;The first script I wrote searched with &lt;code&gt;find&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;find &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;HOME&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/dev"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;HOME&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/content"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s2"&gt;"coreBundle.js"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-path&lt;/span&gt; &lt;span class="s2"&gt;"*/playwright-core/lib/*"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When I ran it, it took &lt;strong&gt;4 minutes 15 seconds&lt;/strong&gt; to complete. There are several hundred &lt;code&gt;node_modules&lt;/code&gt; directories under &lt;code&gt;~/dev&lt;/code&gt;, and find walks every one of them. A 4-minute process every 6 hours wastes CPU and disk even in the background, and worse, I hadn't noticed the "it's slow when you actually run it" fact until the first implementation.&lt;/p&gt;

&lt;p&gt;The layouts where &lt;code&gt;coreBundle.js&lt;/code&gt; actually exists are just two patterns. For a directly installed &lt;code&gt;playwright-core&lt;/code&gt;: &lt;code&gt;*/node_modules/playwright-core/lib/coreBundle.js&lt;/code&gt;. For a nested install via &lt;code&gt;playwright&lt;/code&gt;: &lt;code&gt;*/node_modules/playwright/node_modules/playwright-core/lib/coreBundle.js&lt;/code&gt;. Hitting these two patterns directly with globs down to two levels of repository depth brought the runtime to &lt;strong&gt;0.95 seconds&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Globs are fast because filesystem traversal collapses to OS directory-entry lookups. Where find walks every node, a glob only checks "does an entry matching this pattern exist?" If your search space fits a known structure, glob is clearly superior as a find replacement.&lt;/p&gt;

&lt;h3&gt;
  
  
  Forgot the Ad-Hoc Signature, Got exit 137
&lt;/h3&gt;

&lt;p&gt;My first implementation had &lt;code&gt;cp -R&lt;/code&gt; and the &lt;code&gt;PlistBuddy&lt;/code&gt; rewrite, but no &lt;code&gt;codesign&lt;/code&gt;. When I tried to launch Chrome Automation.app in a test, the helper processes died instantly with &lt;strong&gt;exit 137&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Exit 137 is &lt;code&gt;SIGKILL&lt;/code&gt; (128 + 9). The process didn't exit on its own; the OS forcibly killed it. When macOS Library Validation determines that "the binary exists but its signature doesn't match the bundle ID in Info.plist," it SIGKILLs the helper. Google's signature was issued for &lt;code&gt;com.google.Chrome&lt;/code&gt;, and the moment you change &lt;code&gt;CFBundleIdentifier&lt;/code&gt; in Info.plist to &lt;code&gt;com.google.ChromeAutomation&lt;/code&gt;, it's invalid.&lt;/p&gt;

&lt;p&gt;Adding &lt;code&gt;codesign --force --deep --sign -&lt;/code&gt; and re-running made it launch. &lt;code&gt;--force&lt;/code&gt; overwrites the existing signature, &lt;code&gt;--deep&lt;/code&gt; re-signs not just the main binary but all frameworks, helpers, and plugins, and &lt;code&gt;-&lt;/code&gt; (minus) specifies an ad-hoc signature without an Apple certificate. Gatekeeper doesn't "trust" this signature, but it will launch for local execution. The first time, you get a "developer cannot be verified" confirmation dialog, but after pressing "Open Anyway" once under System Settings → Privacy &amp;amp; Security, it's no longer needed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Zero Results Still Exited 0 as Success
&lt;/h3&gt;

&lt;p&gt;Early on, I misconfigured &lt;code&gt;SCAN_ROOTS&lt;/code&gt;. I wrote &lt;code&gt;~/Development&lt;/code&gt; instead of &lt;code&gt;~/dev&lt;/code&gt;, and since the directory didn't exist, &lt;code&gt;list_core_bundles&lt;/code&gt; returned nothing.&lt;/p&gt;

&lt;p&gt;The script ran to completion and returned exit 0. &lt;code&gt;patched=0&lt;/code&gt; and &lt;code&gt;already=0&lt;/code&gt;, but neither condition tripped, and the log just said &lt;code&gt;playwright patched=0 already=0 rebuilt=0&lt;/code&gt;. launchd only looks at the job's exit code, so it kept recording "success" every 6 hours while nothing was being fixed.&lt;/p&gt;

&lt;p&gt;This is where the worst case happens: after &lt;code&gt;npm install&lt;/code&gt; reverts the path, it can sit unrepaired for up to 6 hours. The code that explicitly fails with &lt;code&gt;exit 1&lt;/code&gt; when &lt;code&gt;patched + already == 0&lt;/code&gt; was born from this failure.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="k"&gt;$((&lt;/span&gt;patched &lt;span class="o"&gt;+&lt;/span&gt; already&lt;span class="k"&gt;))&lt;/span&gt; &lt;span class="nt"&gt;-eq&lt;/span&gt; 0 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;log &lt;span class="s2"&gt;"ERROR: playwright-core が1件も見つからない。SCAN_ROOTS を確認せよ。"&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the exit code is non-zero, launchd records it to &lt;code&gt;StandardErrorPath&lt;/code&gt; and re-runs on the next cycle. Make failures loud. That's the correct design.&lt;/p&gt;

&lt;h3&gt;
  
  
  One Deeply Nested Repo Slipped Through
&lt;/h3&gt;

&lt;p&gt;The first glob pattern only covered one level.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$root&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;/&lt;span class="k"&gt;*&lt;/span&gt;/node_modules/playwright-core/lib/coreBundle.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This single level doesn't catch &lt;code&gt;~/dev/social-autolike/node_modules/playwright/node_modules/playwright-core/lib/coreBundle.js&lt;/code&gt;. That's the case where &lt;code&gt;playwright&lt;/code&gt; is installed containing &lt;code&gt;playwright-core&lt;/code&gt; inside it. &lt;code&gt;social-autolike&lt;/code&gt; had installed &lt;code&gt;playwright&lt;/code&gt; directly, so this nesting occurred.&lt;/p&gt;

&lt;p&gt;As a result, while the other 15 locations pointed at Chrome Automation.app, &lt;code&gt;social-autolike&lt;/code&gt; alone kept directly hitting &lt;code&gt;Google Chrome.app&lt;/code&gt;. When the same problem happened later in &lt;code&gt;dokuji-rosen-sns&lt;/code&gt; (described below), the habit of first checking "has a new nesting pattern appeared?" came from this failure.&lt;/p&gt;

&lt;h3&gt;
  
  
  preflight Was &lt;code&gt;pkill -9&lt;/code&gt;-ing the Human's Chrome Too
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;metrics-hub&lt;/code&gt; had a script called &lt;code&gt;preflight_chrome.sh&lt;/code&gt; whose job was to clean up stale Chrome processes before a job started. It was implemented like this.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pkill &lt;span class="nt"&gt;-9&lt;/span&gt; &lt;span class="s2"&gt;"Google Chrome"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;pkill&lt;/code&gt; does partial matching on the process name. The string &lt;code&gt;Google Chrome&lt;/code&gt; appears in the process names of both &lt;code&gt;com.google.Chrome&lt;/code&gt; and &lt;code&gt;com.google.ChromeAutomation&lt;/code&gt;. Every time an automation job ran preflight, every Chrome window the human had open was SIGKILLed.&lt;/p&gt;

&lt;p&gt;Worse, this breaks in a way that's hard to trace back to a cause. Jobs using Chrome Automation.app run normally. The only remaining symptom is that the human's Chrome suddenly crashes. Nothing shows up in the job logs. Only after the vague awareness of "Chrome keeps crashing" accumulates do you realize preflight is the culprit.&lt;/p&gt;

&lt;p&gt;The fix is to filter by binary path instead of process name.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pkill &lt;span class="nt"&gt;-9&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"Chrome Automation.app/Contents/MacOS"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-f&lt;/code&gt; matches against the process's full command line. By targeting only processes whose command line contains the path &lt;code&gt;Chrome Automation.app/Contents/MacOS&lt;/code&gt;, the implementation never touches the human's Chrome.&lt;/p&gt;

&lt;h3&gt;
  
  
  A 4th Repo Fell Into the Same Hole a Month Later
&lt;/h3&gt;

&lt;p&gt;In the September 1 fix, I changed 16 &lt;code&gt;coreBundle.js&lt;/code&gt; locations and 10 direct-invocation files, and committed 8 repositories. At that point, I believed "everything is covered."&lt;/p&gt;

&lt;p&gt;On September 11, it came out that &lt;code&gt;tools/lib/browser.mjs&lt;/code&gt; in &lt;code&gt;dokuji-rosen-sns&lt;/code&gt; was still on &lt;code&gt;channel:'chrome'&lt;/code&gt;. Even after &lt;code&gt;social-autolike&lt;/code&gt; migrated to the bundled chromium on August 12, &lt;code&gt;dokuji-rosen-sns&lt;/code&gt; remained on &lt;code&gt;channel:'chrome'&lt;/code&gt;. Only between 8:00 and 10:40 in the morning, when it competed with other jobs for Chrome, startup exceeded 180 seconds and timed out — and this continued for 5 days. &lt;strong&gt;39 hangs in 5 days, and the 125 likes in the 8:00 slot vanished every day&lt;/strong&gt;, yet the alert was skimmed past as a single line: "will auto-recover on the next cycle."&lt;/p&gt;

&lt;p&gt;This lesson overlaps with the general form I left in the wiki: "A fix to a shared function means nothing unless it propagates to every call site that uses it." Even if you fix the script, as long as another repo written in the same pattern remains, the fix isn't complete.&lt;/p&gt;

&lt;p&gt;Having both &lt;code&gt;~/dev&lt;/code&gt; and &lt;code&gt;~/content&lt;/code&gt; in &lt;code&gt;SCAN_ROOTS&lt;/code&gt; of &lt;code&gt;chrome-automation-repair.sh&lt;/code&gt; is also to avoid missing a repo that exists in only one of them. Checking "does this repo use &lt;code&gt;channel:'chrome'&lt;/code&gt;?" every time a new repo is created is a human task. But if the script runs every 6 hours and &lt;code&gt;patched&lt;/code&gt; becomes 1 or more, at least the scenario of "a month passes without noticing a miss" is prevented. For now, as long as the script keeps returning &lt;code&gt;patched=0 already=N&lt;/code&gt;, I judge that everything is pointing the right way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfalls
&lt;/h2&gt;

&lt;p&gt;Here's a complete list of the landmines I actually stepped on. Most of "I set it up but it doesn't work" is one of these.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Forgetting codesign after rewriting Info.plist.&lt;/strong&gt; The moment you change &lt;code&gt;CFBundleIdentifier&lt;/code&gt; to &lt;code&gt;com.google.ChromeAutomation&lt;/code&gt;, Google's signature is invalid. Launch without re-signing and Library Validation rejects it; helper processes die instantly with exit 137 (SIGKILL). The &lt;code&gt;--deep&lt;/code&gt; in &lt;code&gt;codesign --force --deep --sign -&lt;/code&gt; is mandatory. With &lt;code&gt;--force&lt;/code&gt; alone, only the main binary is updated, leaving frameworks, helpers, and plugins with the old signature.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Full-walking node_modules with &lt;code&gt;find&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;find ~/dev ~/content -name "coreBundle.js"&lt;/code&gt; took 4 minutes 15 seconds to finish, because there are several hundred &lt;code&gt;node_modules&lt;/code&gt; under &lt;code&gt;~/dev&lt;/code&gt;. &lt;code&gt;coreBundle.js&lt;/code&gt; only exists in two layouts: &lt;code&gt;*/node_modules/playwright-core/lib/&lt;/code&gt; and &lt;code&gt;*/node_modules/playwright/node_modules/playwright-core/lib/&lt;/code&gt;. Hit those directly with globs and the runtime drops to &lt;strong&gt;0.95 seconds&lt;/strong&gt;. There's no need to full-walk a known structure.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Writing only one nesting pattern.&lt;/strong&gt; Projects that install &lt;code&gt;playwright&lt;/code&gt; directly have &lt;code&gt;*/node_modules/playwright-core/lib/coreBundle.js&lt;/code&gt;. Projects where &lt;code&gt;playwright-core&lt;/code&gt; comes in via &lt;code&gt;playwright&lt;/code&gt; have the nested form &lt;code&gt;*/node_modules/playwright/node_modules/playwright-core/lib/coreBundle.js&lt;/code&gt;. Write only one in the glob and you miss the other kind of repo every time. I hit this in &lt;code&gt;social-autolike&lt;/code&gt;: I thought I'd fixed 16 locations, but one was still pointing at the old path.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Zero results still exits 0 as success.&lt;/strong&gt; Put a nonexistent path in &lt;code&gt;SCAN_ROOTS&lt;/code&gt; and &lt;code&gt;list_core_bundles&lt;/code&gt; returns nothing, exiting normally with &lt;code&gt;patched=0 already=0&lt;/code&gt;. launchd records exit 0 as "success," and a "do-nothing job" quietly runs every 6 hours. Only by explicitly failing with &lt;code&gt;exit 1&lt;/code&gt; when &lt;code&gt;patched + already == 0&lt;/code&gt; does an ERROR land in the log and the job re-run on the next cycle.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;pkill -9 "Google Chrome"&lt;/code&gt; kills the human's Chrome too.&lt;/strong&gt; Partial matching on the process name hits both &lt;code&gt;com.google.Chrome&lt;/code&gt; and &lt;code&gt;com.google.ChromeAutomation&lt;/code&gt;. &lt;code&gt;preflight_chrome.sh&lt;/code&gt; in &lt;code&gt;metrics-hub&lt;/code&gt; was exactly this: every time it tried to clean up the automation bundle, it SIGKILLed every tab the human had open. Filter on the full command line with &lt;code&gt;pkill -f "Chrome Automation.app/Contents/MacOS"&lt;/code&gt; and the implementation never touches the human's Chrome.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;launchd's shell doesn't read &lt;code&gt;~/.zshrc&lt;/code&gt;.&lt;/strong&gt; The PATH of the shell launchd starts is only &lt;code&gt;/usr/bin:/bin&lt;/code&gt;. Neither &lt;code&gt;codesign&lt;/code&gt; nor PlistBuddy in &lt;code&gt;/opt/homebrew/bin&lt;/code&gt; is visible. Unless you explicitly set &lt;code&gt;PATH&lt;/code&gt;, &lt;code&gt;HOME&lt;/code&gt;, and &lt;code&gt;LANG&lt;/code&gt; in the plist's &lt;code&gt;EnvironmentVariables&lt;/code&gt;, the script silently dies with "command not found." If &lt;code&gt;LANG&lt;/code&gt; isn't &lt;code&gt;en_US.UTF-8&lt;/code&gt;, &lt;code&gt;grep&lt;/code&gt; may also mangle filenames containing Japanese. It ends with exit 127 and nothing in the log, so discovery is delayed.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Chrome Automation.app's binary gets read mid-rebuild.&lt;/strong&gt; &lt;code&gt;cp -R /Applications/Google\ Chrome.app /Applications/Chrome\ Automation.app&lt;/code&gt; takes 15–30 seconds in the real environment. If an automation job tries to exec &lt;code&gt;Chrome Automation.app/Contents/MacOS/Google Chrome&lt;/code&gt; during that window, it grabs a half-copied binary and crashes. Check for running processes with &lt;code&gt;pgrep -f "Chrome Automation.app/Contents/MacOS"&lt;/code&gt;, and if any exist, defer to the next 6-hour cycle.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Keychain ACLs are invalidated by Chrome updates.&lt;/strong&gt; Chrome Automation.app is ad-hoc signed, so its code hash changes with every rebuild. Even if you set "Always Allow" on "Chrome Safe Storage" in macOS Keychain, when Chrome auto-updates and a rebuild runs, the ACL is invalidated and a "Allow password access?" dialog appears on every headless launch. The right answer is to add &lt;code&gt;--use-mock-keychain --password-store=basic&lt;/code&gt; to every headless launch. However, persistent profiles like Instagram encrypt session cookies with the Keychain key, so switching to mock wipes the login. That's why &lt;code&gt;keychain_gate()&lt;/code&gt; only detects and warns without auto-fixing.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Fixing just one repository means the neighbor hits the same hole a month later.&lt;/strong&gt; Scripts that directly specify &lt;code&gt;channel:'chrome'&lt;/code&gt; are scattered across every repository in &lt;code&gt;~/dev&lt;/code&gt; and &lt;code&gt;~/content&lt;/code&gt;. Fix one, and another repository's &lt;code&gt;browser.mjs&lt;/code&gt; keeps pointing at the old path. &lt;code&gt;dokuji-rosen-sns&lt;/code&gt; actually hit this: only between 8:00 and 10:40 in the morning, competing with other jobs for Chrome, it hung for over 180 seconds — for 5 days straight. For the record, &lt;strong&gt;39 times in 5 days, with the 125 likes in the 8:00 slot vanishing every day&lt;/strong&gt;, and the alert was skimmed as a single line: "auto-recovers on next tick." A fix is only one complete unit once you've cross-checked every repository in &lt;code&gt;SCAN_ROOTS&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Unless you look at both &lt;code&gt;StandardOutPath&lt;/code&gt; and &lt;code&gt;StandardErrorPath&lt;/code&gt;, it looks like "nothing is happening."&lt;/strong&gt; launchd writes stdout and stderr to separate files. If an error only appears in &lt;code&gt;.err.log&lt;/code&gt; and you only check &lt;code&gt;.log&lt;/code&gt;, you won't notice. Build the habit from the start of checking both &lt;code&gt;tail -f ~/.claude/logs/chrome-automation-repair.log&lt;/code&gt; and &lt;code&gt;tail -f ~/.claude/logs/chrome-automation-repair.err.log&lt;/code&gt;, and discovering silent failures shrinks from hours to minutes.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A Gatekeeper dialog appears on Chrome Automation.app's first launch.&lt;/strong&gt; Since an ad-hoc signature isn't on Apple's trust chain, the first time you get "cannot be opened because the developer cannot be verified." Press "Open Anyway" once under System Settings → Privacy &amp;amp; Security and it won't appear again. Easy to forget right after moving the environment to a new Mac or after a major Chrome update (when the bundle is rebuilt).&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Best Practices
&lt;/h2&gt;

&lt;p&gt;Here's the setup above distilled into general principles. Not limited to Chrome bundle isolation — these are design guidelines for any long-running automation on macOS.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Separate human resources from unattended-job resources at the level of their "name."&lt;/strong&gt;&lt;br&gt;
Bundle ID, profile path, port number, PID file. The moment any one of these is shared, you get a structure where "killing one kills the other." Just split the names and the OS isolates them for you. The design cost is one-time; the OS manages it afterward.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Make "npm install will always revert it" a design premise.&lt;/strong&gt;&lt;br&gt;
Don't rely on the fact that a patch was applied. Build "anticipate the revert and re-apply periodically" into the design from the start. A window of up to 6 hours where it reverts to the old path remains, but that's an acceptable known risk. "Fixed" and "stays fixed" are different states.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Count patch results and make zero fail-loud.&lt;/strong&gt;&lt;br&gt;
Keep two variables, &lt;code&gt;patched=N already=M&lt;/code&gt;, and &lt;code&gt;exit 1&lt;/code&gt; if &lt;code&gt;patched + already == 0&lt;/code&gt;. "Searched but found nothing" is equivalent to "did nothing." Silently exiting 0 means a misconfiguration quietly repeats every 6 hours.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Ensure idempotency with version comparison and skip unnecessary rebuilds.&lt;/strong&gt;&lt;br&gt;
Compare &lt;code&gt;src_ver&lt;/code&gt; and &lt;code&gt;dst_ver&lt;/code&gt; via &lt;code&gt;/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString"&lt;/code&gt; and implement "same version, do nothing," and the 6-hour cycle becomes nearly free. A rebuild only runs when Google Chrome auto-updates.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Put a pgrep guard before rebuilding.&lt;/strong&gt;&lt;br&gt;
Check for running processes with &lt;code&gt;pgrep -f "Chrome Automation.app/Contents/MacOS"&lt;/code&gt;, and if any exist, defer to the next cycle. The problem of a binary being grabbed mid-copy is completely prevented by this guard alone. A mechanism that can decide "don't touch it right now" is what creates long-running stability.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Always write PATH, HOME, and LANG in the launchd plist's EnvironmentVariables.&lt;/strong&gt;&lt;br&gt;
launchd reads none of your interactive shell's configuration. Most cases of "the script works locally but silently dies under launchd" are this. Just writing them in the plist from the start brings this class of trouble to zero.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;7. Use glob over find and hit known layouts directly.&lt;/strong&gt;&lt;br&gt;
If you control the structure of what you're searching, a full walk is always overkill. &lt;code&gt;playwright-core&lt;/code&gt; lives in only two patterns. Hitting them directly with globs finishes in 1/270th the time of find.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;8. Use codesign with the three-option set &lt;code&gt;--force --deep --sign -&lt;/code&gt;.&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;--force&lt;/code&gt; overwrites the existing signature, &lt;code&gt;--deep&lt;/code&gt; applies to all frameworks, helpers, and plugins, &lt;code&gt;-&lt;/code&gt; is an ad-hoc signature. Miss even one and Library Validation SIGKILLs. Remember them as a set of three and you won't get stuck.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;9. Add &lt;code&gt;--use-mock-keychain --password-store=basic&lt;/code&gt; to headless Chrome.&lt;/strong&gt;&lt;br&gt;
Only jobs with persistent profiles (Instagram etc.) are the exception: using mock wipes the session, so keep the real Keychain for them. Know which category applies, and for the majority of headless launches, adding mock is the correct setting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;10. Use &lt;code&gt;-f&lt;/code&gt; with pkill for full-path matching. Don't use partial process-name matching.&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;pkill "Google Chrome"&lt;/code&gt; kills both even though the bundle IDs differ. Filter on the full command line with &lt;code&gt;-f "Chrome Automation.app/Contents/MacOS"&lt;/code&gt; and the implementation never touches the human's Chrome.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;11. After a fix, cross-check every repository in SCAN_ROOTS.&lt;/strong&gt;&lt;br&gt;
Fixing one repository isn't complete while another repository written in the same pattern remains. Surface the misses with &lt;code&gt;grep -r "channel:'chrome'" ~/dev ~/content --include="*.mjs" --include="*.js" 2&amp;gt;/dev/null&lt;/code&gt;, and only after handling all of them is it done.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;12. Keep the final verification commands together with the script.&lt;/strong&gt;&lt;br&gt;
Decide up front what you'll verify with after setup. For this setup, it's these four commands.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. launchd への登録確認&lt;/span&gt;
launchctl list | &lt;span class="nb"&gt;grep &lt;/span&gt;chrome-automation-repair

&lt;span class="c"&gt;# 2. バンドルが起動できる状態か&lt;/span&gt;
open &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="s2"&gt;"Chrome Automation"&lt;/span&gt; &lt;span class="nt"&gt;--args&lt;/span&gt; &lt;span class="nt"&gt;--version&lt;/span&gt;

&lt;span class="c"&gt;# 3. playwright が正しいパスを見ているか&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s2"&gt;"Chrome Automation"&lt;/span&gt; ~/dev/&lt;span class="k"&gt;*&lt;/span&gt;/node_modules/playwright-core/lib/coreBundle.js 2&amp;gt;/dev/null | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-5&lt;/span&gt;

&lt;span class="c"&gt;# 4. 人間の Chrome は独立して起動できるか&lt;/span&gt;
open &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="s2"&gt;"Google Chrome"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If number 2's &lt;code&gt;--args --version&lt;/code&gt; returns Chrome's version string (e.g. &lt;code&gt;Google Chrome 127.0.6533.120&lt;/code&gt;) and number 4 opens without &lt;code&gt;-600&lt;/code&gt;, the isolation is working correctly. Thinking of "the setup plus its verification commands as one unit" lowers the cost of reproducing the same environment six months later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;13. Collect logs in &lt;code&gt;~/.claude/logs/&lt;/code&gt; and check both stdout and stderr.&lt;/strong&gt;&lt;br&gt;
Look at only one and you'll keep not noticing that an error is in &lt;code&gt;.err.log&lt;/code&gt;. Set things up so &lt;code&gt;tail -f ~/.claude/logs/*.log ~/.claude/logs/*.err.log&lt;/code&gt; gives you every job's status at a glance, and the cost of discovering silent failures drops.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;14. Choose the repair window (SLA) deliberately and record the rationale.&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;StartInterval: 21600&lt;/code&gt; (6 hours) is an SLA: "even if npm install runs, it's repaired within at most 6 hours." Shorter raises certainty but increases CPU and disk impact. It's important to deliberately choose the maximum your environment can tolerate and leave the rationale in a comment in the plist.&lt;/p&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;If I had to state in one line what remained as a design from that September 1 afternoon when &lt;code&gt;open -a "Google Chrome"&lt;/code&gt; returned &lt;code&gt;-600&lt;/code&gt;: "&lt;strong&gt;Contention only happens while resources are shared. Split the name, and the OS isolates them for you.&lt;/strong&gt;"&lt;/p&gt;

&lt;p&gt;With one automation, the problem is invisible. As you stack up two, five, ten, human resources and automation resources occupy the same namespace, and a structure where killing one breaks the other quietly forms. The more automation you add to grow revenue, the more your own working environment breaks — noticing that paradox was the starting point of this design.&lt;/p&gt;

&lt;p&gt;The mechanism stabilized the moment I stopped viewing "npm install reverts it" as a "problem that must be fixed" and accepted it as "periodic repair on the premise that it reverts." Even if the patch reverts, it's fixed within 6 hours. Building that repair window into the design as a known risk is the key to long-running operation.&lt;/p&gt;

&lt;p&gt;Now that &lt;code&gt;chrome-automation-repair.sh&lt;/code&gt; runs every 6 hours and keeps returning &lt;code&gt;patched=0 already=16 rebuilt=0&lt;/code&gt;, the automation jobs use Chrome Automation.app and the human's Chrome runs independently. Mornings like September 1 don't come anymore.&lt;/p&gt;

&lt;p&gt;What's the one shared resource in your automation setup that you and your jobs are still fighting over — and how long has it been silently losing?&lt;/p&gt;




&lt;p&gt;The full picture of the system, the breakdown of the ¥1.2M/month, and the 30-day procedure are compiled in a paid note (Japanese).&lt;/p&gt;

&lt;p&gt;📕 &lt;a href="https://note.com/bokuwalily/n/n849b3a07784a" rel="noopener noreferrer"&gt;How to actually earn with a Claude Code autonomous environment — mechanism, real examples, getting started, and support&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by **Lily&lt;/em&gt;* — I ship iOS apps and automate my content stack with Claude Code.&lt;br&gt;
Follow along: &lt;a href="https://bokuwalily.com" rel="noopener noreferrer"&gt;Portfolio&lt;/a&gt; · &lt;a href="https://x.com/bokuwalily" rel="noopener noreferrer"&gt;X&lt;/a&gt; · &lt;a href="https://github.com/bokuwalily" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;*&lt;/p&gt;

</description>
      <category>automation</category>
      <category>macos</category>
      <category>playwright</category>
      <category>devops</category>
    </item>
    <item>
      <title>Wi-Fi Was Dead for 5 Hours While Chrome Kept Working: 4,960 DNS Failures and the 5-Minute Probe That Catches Them</title>
      <dc:creator>Lily</dc:creator>
      <pubDate>Fri, 18 Sep 2026 05:00:06 +0000</pubDate>
      <link>https://dev.to/bokuwalily/wi-fi-was-dead-for-5-hours-while-chrome-kept-working-4960-dns-failures-and-the-5-minute-probe-3gp5</link>
      <guid>https://dev.to/bokuwalily/wi-fi-was-dead-for-5-hours-while-chrome-kept-working-4960-dns-failures-and-the-5-minute-probe-3gp5</guid>
      <description>&lt;p&gt;Last time I wrote about &lt;a href="https://dev.to/bokuwalily/15-launchd-jobs-and-one-quota-circuit-breaker-deciding-what-to-re-run-once-the-circuit-closes-3f95"&gt;how many jobs you should re-run the moment your quota comes back&lt;/a&gt;. This post goes back a few days earlier, to a quieter and far more annoying problem: the morning my home Wi-Fi was effectively dead for five hours — while the browser kept loading pages as if nothing were wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: the browser works, but only the jobs die
&lt;/h2&gt;

&lt;p&gt;On the morning of 2026-09-12, four unattended jobs failed one after another.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Job&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;com.lily.es-daily-rows&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;05:01&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;com.lily.paid-note-pin-guard&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;07:20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;com.lily.line-column-gen&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;08:40&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;com.lily.line-pdca&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;08:41&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The errors were &lt;code&gt;Node fetch failed&lt;/code&gt; and &lt;code&gt;python getaddrinfo&lt;/code&gt; failures. Yet during that same window, browsing in Chrome worked perfectly. This is the worst kind of failure: "the network is up, but some processes die anyway."&lt;/p&gt;

&lt;p&gt;Digging into the unified log, &lt;code&gt;airportd&lt;/code&gt; had been emitting &lt;code&gt;SlowWiFiDnsFailure&lt;/code&gt; at an abnormal rate. In the last 24 hours there were &lt;strong&gt;4,960&lt;/strong&gt; of them. They ramped up around 05:43, ran at 1,000–1,700+ per hour through the 06:00–11:00 window, peaked at &lt;strong&gt;1,720&lt;/strong&gt; in the 10 o'clock hour, and stopped cold after 12:38. The human action that ended it came at 13:09: I manually switched from home Wi-Fi to my phone's tethering. After the switch, 37 probe runs showed &lt;strong&gt;failed: 0&lt;/strong&gt;, with lookups under 60ms and connects under 100ms on both IPv4 and IPv6.&lt;/p&gt;

&lt;p&gt;The culprit was that only the DNS path was dead. Chrome has its own DNS resolution (DoH plus a cache), so it sails on even when the home router's DNS is rotten. Node and Python go straight to the system resolver — the home router's DNS — and get stuck. &lt;strong&gt;The intuition that "the browser works, so the network is fine" was itself the diagnostic trap.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfalls I hit (diagnosis edition)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;I almost misread a symlink's mtime as "we've been on tethering for three weeks"&lt;/strong&gt; → &lt;code&gt;/etc/resolv.conf&lt;/code&gt; is a symlink, and its mtime still said Aug 15. Checking the real file, &lt;code&gt;/var/run/resolv.conf&lt;/code&gt;, its mtime matched the switchover time (13:09:39) exactly, so the first reading was wrong. You always have to know whether you're looking at the symlink's mtime or the target's.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unified log retention windows differ per facility&lt;/strong&gt; → &lt;code&gt;configd&lt;/code&gt; only had entries from 05:54 onward, while &lt;code&gt;airportd&lt;/code&gt; went back to 19:00 the previous day. I thought I was comparing two facilities on the same timeline, but one of them had already dropped data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The name &lt;code&gt;SlowWiFiDnsFailure&lt;/code&gt; is misleading&lt;/strong&gt; → It's logged as "slow," not "dead," so even grepping for anomalies requires a threshold decision.&lt;/li&gt;
&lt;li&gt;Node fetch failed appeared &lt;strong&gt;343 times&lt;/strong&gt; in that same 24 hours. The apparent gap between the job failure times (05:01–08:41) and the start of the &lt;code&gt;airportd&lt;/code&gt; burst (05:43 onward) is also explained by the difference in facility retention.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note&lt;/strong&gt;&lt;br&gt;
On that day I was able to dig through the unified log afterward and pin down "roughly 5,000 events between 06:00 and 12:38." But &lt;strong&gt;that only worked because the incident happened to be large enough to stand out.&lt;/strong&gt; If the same thing happens next time for a shorter period or at lower frequency, it may be past the unified log's retention window and leave nothing behind. What I needed was a way to trace root causes that doesn't depend on "digging through the log after the fact" — and that's the real subject of this post.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Never chasing it after the fact again: net-probe.sh
&lt;/h2&gt;

&lt;p&gt;What I built is a script that does one thing: every 5 minutes it appends a one-line JSONL "snapshot of the network state." It uses neither Chrome nor claude, and the target is to finish in &lt;strong&gt;under 20 seconds&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# 5分毎の軽量ネットワークプローブ（Chrome/claude 不使用・目標20秒以内)。&lt;/span&gt;
&lt;span class="c"&gt;# gateway / SSID / nameserver / dig 3ホスト / curl -4,-6 2ホスト / python getaddrinfo を&lt;/span&gt;
&lt;span class="c"&gt;# 1行JSONで ~/.claude/logs/net-probe.jsonl へ追記する。&lt;/span&gt;
&lt;span class="c"&gt;# 目的: Node fetch failed / python getaddrinfo 失敗が「どの回線・どのDNSで」起きたかを後から突合する。&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key is to &lt;strong&gt;fire everything in parallel&lt;/strong&gt;. Fetching the SSID via &lt;code&gt;system_profiler&lt;/code&gt; alone takes 5 seconds, so it gets pushed into the background alongside the other collection.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# --- SSID は system_profiler が5秒かかるので並列 ---&lt;/span&gt;
&lt;span class="o"&gt;(&lt;/span&gt; system_profiler SPAirPortDataType 2&amp;gt;/dev/null | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'/Current Network Information:/{getline; gsub(/^ +| *:$/,""); print; exit}'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TMP&lt;/span&gt;&lt;span class="s2"&gt;/ssid"&lt;/span&gt; &lt;span class="o"&gt;)&lt;/span&gt; &amp;amp;

&lt;span class="c"&gt;# --- DNS: システム順序で3ホスト + 各NSで oauth2 (すべて並列, 2秒×1回) ---&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;h &lt;span class="k"&gt;in &lt;/span&gt;oauth2.googleapis.com discord.com note.com&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="o"&gt;(&lt;/span&gt; dig +time&lt;span class="o"&gt;=&lt;/span&gt;2 +tries&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$h&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; A &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TMP&lt;/span&gt;&lt;span class="s2"&gt;/dig.&lt;/span&gt;&lt;span class="nv"&gt;$h&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;&amp;amp;1 &lt;span class="o"&gt;)&lt;/span&gt; &amp;amp;
&lt;span class="k"&gt;done
&lt;/span&gt;&lt;span class="nv"&gt;i&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
&lt;span class="k"&gt;for &lt;/span&gt;ns &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;NS&lt;/span&gt;&lt;span class="p"&gt;//,/ &lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="o"&gt;(&lt;/span&gt; dig +time&lt;span class="o"&gt;=&lt;/span&gt;2 +tries&lt;span class="o"&gt;=&lt;/span&gt;1 @&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ns&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; oauth2.googleapis.com A &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TMP&lt;/span&gt;&lt;span class="s2"&gt;/digns.&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;&amp;amp;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ns&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TMP&lt;/span&gt;&lt;span class="s2"&gt;/digns.&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="s2"&gt;.ns"&lt;/span&gt; &lt;span class="o"&gt;)&lt;/span&gt; &amp;amp;
  &lt;span class="nv"&gt;i&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;i+1&lt;span class="k"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;dig&lt;/code&gt; uses &lt;code&gt;+time=2 +tries=1&lt;/code&gt; so it always gives up at 2 seconds, &lt;code&gt;curl&lt;/code&gt; uses &lt;code&gt;--max-time 6&lt;/code&gt;, and Python's &lt;code&gt;getaddrinfo&lt;/code&gt; runs in a thread with &lt;code&gt;join(5)&lt;/code&gt; to force a 5-second cutoff. Every timeout has to be explicit, because when DNS is truly dead, &lt;strong&gt;the probe itself would hang and miss the next 5-minute slot.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;f&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getaddrinfo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;oauth2.googleapis.com&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ok&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;ms&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ok&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;__name__&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;ms&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;th&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;threading&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Thread&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;daemon&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;th&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="n"&gt;th&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ok&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;timeout&amp;gt;5s&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ms&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once &lt;code&gt;wait&lt;/code&gt; confirms all the fragments are in, the one-line JSON is assembled in Python rather than bash. Hand-building JSON via string concatenation breaks the moment an SSID contains a space or a special character, so this one part is left to &lt;code&gt;json.dumps&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;gw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;GW&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;fail&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;dns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;NOERROR&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;http&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;py&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ok&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;rec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ts&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;TS&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;gateway&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;gw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;gateway6&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;GW6&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;iface&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;IFACE&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ip4&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;IP4&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ssid&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;rd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/ssid&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;security&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;SEC&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;isIphoneHotspot&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;gw&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;172.20.10.1&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;resolvers&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;NS&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;,&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;utun&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;UTUN&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;dns&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;dns&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;dnsPerResolver&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;per_ns&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;http&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;pyGetaddrinfo&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;py&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;failures&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;fail&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;isIphoneHotspot&lt;/code&gt; is determined by whether the gateway IP is &lt;code&gt;172.20.10.1&lt;/code&gt; (the default address for iPhone tethering). Instead of eyeballing the gateway IP every time, this single field tells you at a glance which connection you're on. &lt;code&gt;failures&lt;/code&gt; is the sum of dns / http / pyGetaddrinfo failures, so pulling anomalous lines out of the JSONL is just &lt;code&gt;grep '"failures":[1-9]'&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  launchd: run it quietly every 5 minutes
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;StartInterval&lt;/code&gt; gives a fixed-interval launch, and the priority is lowered so it stays strictly in the background.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;StartInterval&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;integer&amp;gt;&lt;/span&gt;300&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;LowPriorityIO&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;true/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;Nice&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;integer&amp;gt;&lt;/span&gt;10&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;ProcessType&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;Background&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Nice 10&lt;/code&gt; plus &lt;code&gt;LowPriorityIO&lt;/code&gt; ensures it never steals CPU or IO from the other automation jobs. A job whose purpose is to watch whether the network is alive should not itself become a source of resource contention.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four days of real measurements
&lt;/h2&gt;

&lt;p&gt;From deployment until today, it has accumulated &lt;strong&gt;662 lines&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; ~/.claude/logs/net-probe.jsonl
&lt;span class="go"&gt;662
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Broken down by gateway: home Wi-Fi (&lt;code&gt;192.168.3.1&lt;/code&gt;) accounts for 594 entries, iPhone tethering (&lt;code&gt;172.20.10.1&lt;/code&gt;) for 68. Lines matching &lt;code&gt;"failures":[1-9]&lt;/code&gt; number 50 — about 7.6% of 662. Some days had zero, while the hour &lt;code&gt;2026-09-13T09&lt;/code&gt; alone had 5 failures clustered together — unevenness that would have been flattened out without 5-minute granularity.&lt;/p&gt;

&lt;p&gt;A single line looks like this (excerpt from the actual log).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"ts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2026-09-16T20:28:45+0900"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"gateway"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"192.168.3.1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"iface"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"en0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"security"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"WPA2_PSK"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"isIphoneHotspot"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"dns"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"oauth2.googleapis.com"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"NOERROR"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;17&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"answers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"err"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"discord.com"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"NOERROR"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;17&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"answers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"err"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"note.com"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"NOERROR"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"answers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"err"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;}},&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"http"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"v4:discord.com"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"http"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"lookup"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;0.004285&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"connect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;0.013422&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"total"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;0.115357&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"pyGetaddrinfo"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"ok"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"n"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;107&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="nl"&gt;"failures"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Cross-referencing: reconstructing when, which connection, and which DNS after the fact
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;network-flap-probe-2026-09-12.json&lt;/code&gt; I put together right after the incident still holds &lt;code&gt;verdict: "dns-resolver-flap"&lt;/code&gt; along with the explanation of the cause, exactly as written that day (in Japanese).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"verdictDetail"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"症状の発生源は自宅Wi-Fiの DNS 経路。airportd が 06:00-12:38 に
SlowWiFiDnsFailure を約5,000件（毎時1,000-1,700件）記録しており、failing 4 ジョブと
fetch failed 343件はこの回線上で発生。13:09に人間が iPhone テザリングへ手動切替した後は
fault 0・全プローブ成功。Chrome 経由ジョブが通っていたのは Chrome が独自 DNS
(DoH/非同期リゾルバ+キャッシュ)を使い、Node/pythonはシステムリゾルバ
（自宅ルータ DNS）を直撃するため"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In short, it says: the source of the symptoms was the home Wi-Fi's DNS path; &lt;code&gt;airportd&lt;/code&gt; recorded roughly 5,000 &lt;code&gt;SlowWiFiDnsFailure&lt;/code&gt; events (1,000–1,700 per hour) between 06:00 and 12:38; the 4 failing jobs and the 343 fetch failures all occurred on that connection; after the manual switch to iPhone tethering at 13:09, faults dropped to 0 and every probe succeeded; and the Chrome-based jobs kept working because Chrome uses its own DNS (DoH / async resolver + cache) while Node and Python hit the system resolver (the home router's DNS) directly.&lt;/p&gt;

&lt;p&gt;That day I dug all the way there by hand. With &lt;code&gt;net-probe.jsonl&lt;/code&gt;, the same conclusion can be reached &lt;strong&gt;mechanically, at 5-minute granularity&lt;/strong&gt;. The &lt;code&gt;gateway&lt;/code&gt; field records the connection, and the &lt;code&gt;dnsPerResolver&lt;/code&gt; field records how each individual DNS server responded to its query, so "when, on which connection, against which DNS server" can be reconstructed just by filtering fields. Whether a manual reproduction step (rebooting the router, pinning DNS to 1.1.1.1/8.8.8.8) is even needed can be decided case by case from this record.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Wi-Fi can be "connected" while &lt;strong&gt;only the DNS path&lt;/strong&gt; is dead. Chrome carrying on as normal is because it has its own DNS cache — it is not evidence of health.&lt;/li&gt;
&lt;li&gt;If root-cause tracing depends on "digging through the unified log after the incident," you run into traps like per-facility retention differences and misreading a symlink's mtime.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;net-probe.sh&lt;/code&gt; is a thin mechanism that fires gateway / SSID / DNS / HTTP (v4, v6) / getaddrinfo all in parallel with timeouts and appends one JSONL line in under 20 seconds.&lt;/li&gt;
&lt;li&gt;With &lt;code&gt;launchd&lt;/code&gt;'s &lt;code&gt;StartInterval=300&lt;/code&gt; + &lt;code&gt;Nice 10&lt;/code&gt; + &lt;code&gt;LowPriorityIO&lt;/code&gt;, it runs quietly every 5 minutes without competing with other jobs.&lt;/li&gt;
&lt;li&gt;662 lines in 4 days, 50 of them with failures&amp;gt;0 (7.6%). With this accumulated, the next time the same thing happens, reaching the cause means "filter the fields," not "dig after the fact."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Using this JSONL, I'd like to grow it to the point where a detected anomaly automatically notifies Discord and, if needed, switches connections automatically. That's for another post.&lt;/p&gt;

&lt;p&gt;Have you ever had a job fail while the browser insisted everything was fine — and how long did it take you to find out DNS was the reason?&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by **Lily&lt;/em&gt;* — I ship iOS apps and automate my content stack with Claude Code.&lt;br&gt;
Follow along: &lt;a href="https://bokuwalily.com" rel="noopener noreferrer"&gt;Portfolio&lt;/a&gt; · &lt;a href="https://x.com/bokuwalily" rel="noopener noreferrer"&gt;X&lt;/a&gt; · &lt;a href="https://github.com/bokuwalily" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;*&lt;/p&gt;

</description>
      <category>macos</category>
      <category>networking</category>
      <category>bash</category>
      <category>devops</category>
    </item>
    <item>
      <title>15 launchd Jobs and One Quota Circuit Breaker: Deciding What to Re-run Once the Circuit Closes</title>
      <dc:creator>Lily</dc:creator>
      <pubDate>Fri, 18 Sep 2026 00:00:03 +0000</pubDate>
      <link>https://dev.to/bokuwalily/15-launchd-jobs-and-one-quota-circuit-breaker-deciding-what-to-re-run-once-the-circuit-closes-3f95</link>
      <guid>https://dev.to/bokuwalily/15-launchd-jobs-and-one-quota-circuit-breaker-deciding-what-to-re-run-once-the-circuit-closes-3f95</guid>
      <description>&lt;p&gt;A circuit breaker that halts every job the moment your quota runs dry is only half the story. The harder question shows up afterward: once the circuit closes again, what do you do with everything it skipped? In my &lt;a href="https://dev.to/bokuwalily/12-files-in-4-out-the-secret-scanning-gate-between-claude-codes-audit-memos-and-my-obsidian-wiki-19dd"&gt;previous post I wrote about fixing the Wiki secret-scan sync&lt;/a&gt;. This time I'm switching gears and following up on &lt;code&gt;claude-quota-guard.py&lt;/code&gt;, the &lt;strong&gt;quota circuit breaker&lt;/strong&gt; I built. That script's story ended at "detect quota exhaustion, stop all jobs." In real operation, there's a whole second problem waiting past that point—and along the way it involved a 1200-second timeout that turned out to need 2700, and a load average that climbed past 40 when I got it wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: the circuit closes, but skipped jobs don't come back
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;run_job&lt;/code&gt; in &lt;code&gt;claude-quota-guard.py&lt;/code&gt; returns exit 0 immediately when a job starts while the circuit is open, leaving nothing but a marker in the log.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CLAUDE_QUOTA_JOB_SKIPPED &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;job=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; reason=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;reason&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; remaining=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;remaining_seconds&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;s ts=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nb"&gt;file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stderr&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This guard sits in front of 15 of the plists under &lt;code&gt;~/Library/LaunchAgents/*.plist&lt;/code&gt;. As the comment in the code puts it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# 🔴 2026-08-21: circuit が開くと全ジョブが一律で止まるため、消費の大半を占める
# 返信/エンゲージ系がクォータを使い切った巻き添えで「投稿」まで停止していた。
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The problem is that after the circuit closes and &lt;code&gt;open_until&lt;/code&gt; has passed, launchd &lt;strong&gt;does nothing until that job's next &lt;code&gt;StartCalendarInterval&lt;/code&gt; slot comes around&lt;/strong&gt;. If the 9:00 AM job was skipped for quota reasons and the quota recovers at 10:00, but the next slot is 9:00 AM tomorrow, you've lost an entire day's execution. Deciding "what to re-run, and how much, after recovery" is the job of &lt;code&gt;quota-catchup.py&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design: three filters decide the candidates
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;quota-catchup.py&lt;/code&gt; walks every plist in &lt;code&gt;find_candidates&lt;/code&gt; and only treats a job as a re-run candidate if it passes three conditions ANDed together.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Condition&lt;/th&gt;
&lt;th&gt;Function&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Is this plist guarded?&lt;/td&gt;
&lt;td&gt;&lt;code&gt;is_guarded&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Don't mix in unrelated jobs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Is today's latest marker SKIPPED?&lt;/td&gt;
&lt;td&gt;&lt;code&gt;latest_marker_is_today_skip&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Exclude jobs that already ran, and stale skips&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Has a slot already passed?&lt;/td&gt;
&lt;td&gt;&lt;code&gt;calendar_slot_passed&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Exclude &lt;code&gt;StartInterval&lt;/code&gt; jobs and jobs with only future slots&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;find_candidates&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Candidate&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Candidate&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;plist_path&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;launch_agents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;glob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*.plist&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
        &lt;span class="n"&gt;plist&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;load_plist&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plist_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;plist&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;is_guarded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plist&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="bp"&gt;...&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;already_kicked&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;latest_marker_is_today_skip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;output_paths&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;today&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;calendar_slot_passed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plist&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Candidate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;plist_path&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;candidates&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Preventing double launches: is_guarded
&lt;/h3&gt;

&lt;p&gt;Re-run targets are limited to jobs that are "launched via &lt;code&gt;claude-quota-guard.py&lt;/code&gt;." The check is nothing more than a string match on &lt;code&gt;ProgramArguments&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;is_guarded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plist&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;arguments&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;plist&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ProgramArguments&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;arguments&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;claude-quota-guard&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;arguments&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you picked up plists that don't go through the guard, you'd end up kicking ordinary cron-style jobs that have nothing to do with the quota.&lt;/p&gt;

&lt;h3&gt;
  
  
  Deciding "has a slot already passed?": calendar_slot_passed
&lt;/h3&gt;

&lt;p&gt;This is the star of the show. A single job can have multiple &lt;code&gt;StartCalendarInterval&lt;/code&gt; slots. A real example is &lt;code&gt;com.shun.daily-brief.plist&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;StartCalendarInterval&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;array&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;dict&amp;gt;&amp;lt;key&amp;gt;&lt;/span&gt;Hour&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;8&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&amp;lt;key&amp;gt;&lt;/span&gt;Minute&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;0&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;dict&amp;gt;&amp;lt;key&amp;gt;&lt;/span&gt;Hour&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;10&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&amp;lt;key&amp;gt;&lt;/span&gt;Minute&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;30&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/array&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Even if the 8:00 slot gets skipped for quota, the same job naturally runs again at 10:30. So the rule is "if even one slot has passed, it's a re-run candidate," not "wait, because there's still a future slot." Conversely, &lt;code&gt;StartInterval&lt;/code&gt; jobs (e.g., every 30 minutes) will naturally re-run on the next interval if you just leave them alone, so there's no need to make them catch-up targets.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;calendar_slot_passed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;plist&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;True only for calendar-only jobs with at least one past slot today.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;StartInterval&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;plist&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="n"&gt;raw_entries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;plist&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;StartCalendarInterval&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_entries&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;entries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;raw_entries&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_entries&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;entries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;raw_entries&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;

    &lt;span class="n"&gt;saw_today_slot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Hour&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;runs_today&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;scheduled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;hour&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Hour&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
                &lt;span class="n"&gt;minute&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Minute&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
                &lt;span class="n"&gt;second&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;microsecond&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;except &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;TypeError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;scheduled&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="n"&gt;saw_today_slot&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;saw_today_slot&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key is walking every slot to the end and accumulating &lt;code&gt;saw_today_slot&lt;/code&gt; with an OR. If you'd written it as "return the verdict from the first slot you find," you'd hit a bug where the result depends on the order of the slots. For example, with the ordering &lt;code&gt;[{9:00}, {18:00}]&lt;/code&gt; evaluated at 13:00, the correct answer is "candidate" because 9:00 is in the past—but if the loop only judged by the last entry, it would misclassify the job as not a candidate on the grounds that 18:00 is in the future.&lt;/p&gt;

&lt;h3&gt;
  
  
  A timeout tuned from real measurements: JOB_TIMEOUT_SECONDS
&lt;/h3&gt;

&lt;p&gt;A re-run goes through &lt;code&gt;kick_and_wait&lt;/code&gt;, which runs &lt;code&gt;launchctl kickstart&lt;/code&gt; and then polls until the job leaves launchd's management (i.e., exits). This timeout started at 1200 seconds, which turned out to be insufficient in practice.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# 実測(2026-08-21): affameba-gen 等の claude 生成レーンは 20 分を超える。
# 1200s だと「待つのをやめて次を kick」するだけで前のジョブは生き続け、
# runbook が要求する直列 kick が崩れて重い生成が重なる（load 40 超の二次被害）。
&lt;/span&gt;&lt;span class="n"&gt;JOB_TIMEOUT_SECONDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2700&lt;/span&gt;
&lt;span class="c1"&gt;# timeout 時は待つのをやめるだけでなく実際に止める。ここを殺さないと直列性が保てない。
&lt;/span&gt;&lt;span class="n"&gt;JOB_KILL_GRACE_SECONDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The lesson here: "stop waiting" and "stop the job" are two different things. With only the former, the timed-out old job kept running in the background while the next candidate got kicked, the generation workloads piled up, and the load average went past 40 as collateral damage. So &lt;code&gt;terminate_job&lt;/code&gt; actually kills the job—SIGTERM, then SIGKILL—to guarantee serial execution.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;terminate_job&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;domain_label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;timeout したジョブを実際に止める。次の kick と重ならせないための直列性の担保。&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;signal_name&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SIGTERM&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SIGKILL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;launchctl&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kill&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;signal_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;domain_label&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;capture_output&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;check&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;deadline&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monotonic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;JOB_KILL_GRACE_SECONDS&lt;/span&gt;
        &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;monotonic&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;deadline&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;launchctl_status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;pid&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt;
            &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Pinning down the edge cases with tests: test_quota_catchup.py
&lt;/h2&gt;

&lt;p&gt;This script has 18 test cases (17 in &lt;code&gt;unittest&lt;/code&gt;, plus 1 &lt;code&gt;pytest&lt;/code&gt;-style function). Writing this many tests for a personal automation script might look like overkill, so here are the cases where they actually earned their keep.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't call claude on days with zero candidates&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is the one with the biggest real-world cost. The comment in the &lt;code&gt;run&lt;/code&gt; function explains why.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# 拾うものが無い日に probe を撃つと、30分おきに claude -p を1日48回空撃ちして
# クォータを削る（このジョブが防ごうとしている事故そのものを起こす）。
# 候補が出た時だけ回復を確認する。
&lt;/span&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test that protects this is &lt;code&gt;test_no_candidates_skips_probe&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_no_candidates_skips_probe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_job&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;error_text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CLAUDE_QUOTA_JOB_RAN job=com.lily.test exit=0 ts=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;probe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Mock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;return_value&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;kicker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Mock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;quota_catchup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;dry_run&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;state_path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;catchup_path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;catchup&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;launch_agents&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;agents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;log_path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;root&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;result.log&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;probe&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;probe&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;kicker&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;kicker&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;([],&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;probe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assert_not_called&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;kicker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assert_not_called&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"The batch that checks whether the quota has recovered burns quota just by checking" is a self-contradiction that anyone who built a circuit breaker absolutely does not want to step into. That single line, &lt;code&gt;probe.assert_not_called()&lt;/code&gt;, guarantees it mechanically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Today's skip is a candidate; a skip from three days ago is not&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_today_skip_is_candidate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_job&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertEqual&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;()],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_old_skip_is_not_candidate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_job&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mtime&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;days&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;latest_marker_is_today_skip&lt;/code&gt; uses a regex to pull &lt;code&gt;ts=&lt;/code&gt; out of the log and checks whether the date is today. If a SKIPPED log from three days ago got picked up again today, you'd have a zombie state where past failures get re-run every single day.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A past slot makes it a candidate even if a future slot remains&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_past_slot_makes_candidate_even_if_later_slot_is_future&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_job&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;schedule&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Hour&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Minute&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Hour&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;18&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Minute&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;}])&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertEqual&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;()],&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;com.lily.test&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This test pins the OR logic in &lt;code&gt;calendar_slot_passed&lt;/code&gt; described above, using a real &lt;code&gt;daily-brief&lt;/code&gt;-style schedule (multiple slots).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't get the marker order wrong&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The log contains both &lt;code&gt;SKIPPED&lt;/code&gt; and &lt;code&gt;RAN&lt;/code&gt;. If you misjudge which one is the latest marker, you'll either double-kick a job that actually succeeded, or miss a separate skip that happened after a success.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_ran_marker_after_skip_in_same_log_is_not_candidate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;com.lily.mixed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;today&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_job&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;error_text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CLAUDE_QUOTA_JOB_SKIPPED job=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; reason=quota remaining=1s ts=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;today&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CLAUDE_QUOTA_JOB_RAN job=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; exit=0 ts=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;today&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assertEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;candidates&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If RAN comes after SKIPPED, the job "succeeded later after all" and is not a candidate. &lt;code&gt;latest_job_marker&lt;/code&gt; guarantees this chronological judgment by scanning &lt;code&gt;reversed(lines)&lt;/code&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note&lt;/strong&gt;&lt;br&gt;
The common goal of this script's unit tests is not "prove the clever logic is correct" but "&lt;strong&gt;pin down, ahead of time, the boundaries a naive implementation gets wrong&lt;/strong&gt;." The OR logic in calendar_slot_passed, the old-vs-new marker judgment, suppressing the probe when there are zero candidates—each of these flips its result depending on how a single line is written, and none of them are easy to notice until you actually run it. The value of writing pytest for a personal automation script isn't to convince a reviewer; it's so that &lt;strong&gt;six months from now, when you change the spec, you don't step on the same mistake again&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Pitfalls I hit
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A 1200-second timeout cut off jobs that run over 20 minutes&lt;/strong&gt; → Extended to 2700 seconds based on measurements, with &lt;code&gt;JOB_KILL_GRACE_SECONDS=30&lt;/code&gt; providing the SIGTERM→SIGKILL grace period&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Merely "stop waiting" on timeout breaks serial execution&lt;/strong&gt; → Without actually killing via &lt;code&gt;terminate_job&lt;/code&gt;, the previous job stays alive while the next one gets kicked, causing the load-over-40 collateral damage&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Firing the probe on a day with zero candidates causes the very accident you're trying to prevent&lt;/strong&gt; → Only call &lt;code&gt;probe_claude()&lt;/code&gt; when there are candidates&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Picking up StartInterval jobs causes unnecessary re-runs&lt;/strong&gt; → If &lt;code&gt;StartInterval&lt;/code&gt; is present, &lt;code&gt;calendar_slot_passed&lt;/code&gt; is unconditionally False&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Judging SKIPPED/RAN markers by "does it exist" alone leads to misclassification&lt;/strong&gt; → Look only at the latest marker via &lt;code&gt;reversed(lines)&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hitting claude on every &lt;code&gt;--dry-run&lt;/code&gt; turns verification cost into execution cost&lt;/strong&gt; → dry-run doesn't call the probe; it just returns the candidate list&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A quota circuit breaker doesn't end at "stop"—unless you also design &lt;strong&gt;what to re-run after it closes&lt;/strong&gt;, recovery waits until the next day&lt;/li&gt;
&lt;li&gt;Re-run candidates are the three-stage AND of &lt;code&gt;is_guarded&lt;/code&gt; / &lt;code&gt;latest_marker_is_today_skip&lt;/code&gt; / &lt;code&gt;calendar_slot_passed&lt;/code&gt;. &lt;strong&gt;If even one slot has passed, it's a candidate; whether future slots exist is irrelevant&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Set the timeout from real measurements (jobs over 20 minutes actually exist), and &lt;strong&gt;when you cut a job off, actually kill it to preserve serial execution&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;With zero candidates, &lt;strong&gt;don't even call the probe&lt;/strong&gt;. Avoid the self-contradiction of a recovery-check batch eating its own quota&lt;/li&gt;
&lt;li&gt;Unit tests for personal scripts exist to &lt;strong&gt;pin down in advance&lt;/strong&gt; the boundaries that naive implementations tend to flip (slot order, marker recency, side effects on zero-count days)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next time I plan to write about the circuit breaker itself—how &lt;code&gt;claude-quota-guard.py&lt;/code&gt; distinguishes a genuine "limit reached" message from a successful run whose article body just happens to contain the same words.&lt;/p&gt;

&lt;p&gt;How do you handle catch-up for scheduled jobs that got skipped in your own setup—do you re-run them, or just wait for the next slot?&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by **Lily&lt;/em&gt;* — I ship iOS apps and automate my content stack with Claude Code.&lt;br&gt;
Follow along: &lt;a href="https://bokuwalily.com" rel="noopener noreferrer"&gt;Portfolio&lt;/a&gt; · &lt;a href="https://x.com/bokuwalily" rel="noopener noreferrer"&gt;X&lt;/a&gt; · &lt;a href="https://github.com/bokuwalily" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;*&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>automation</category>
      <category>launchd</category>
      <category>python</category>
    </item>
    <item>
      <title>4 Ways a 'Retired' launchd Job Kept Running on a 160+ Job Fleet — and the 3 Commands That Actually Stop It</title>
      <dc:creator>Lily</dc:creator>
      <pubDate>Thu, 17 Sep 2026 11:00:06 +0000</pubDate>
      <link>https://dev.to/bokuwalily/4-ways-a-retired-launchd-job-kept-running-on-a-160-job-fleet-and-the-3-commands-that-actually-1fk6</link>
      <guid>https://dev.to/bokuwalily/4-ways-a-retired-launchd-job-kept-running-on-a-160-job-fleet-and-the-3-commands-that-actually-1fk6</guid>
      <description>&lt;p&gt;I run more than 160 launchd jobs on a Mac that almost never reboots. That fleet is what carried me from a ¥100k/month student side gig to ¥600k juggling multiple jobs, back down to zero after a layoff, and then — six months of rebuilding a Claude Code autonomous setup later — past ¥1.2M/month in revenue. Keeping that automation alive has taught me something I didn't expect: cleanly &lt;em&gt;stopping&lt;/em&gt; a job is far harder than starting one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Renaming a file to &lt;code&gt;.plist.retired&lt;/code&gt; does not stop the job. launchd keeps running it.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this happens
&lt;/h2&gt;

&lt;p&gt;launchd doesn't identify jobs by file name. It identifies them by the value of the &lt;code&gt;Label&lt;/code&gt; key inside the plist.&lt;/p&gt;

&lt;p&gt;Take a file that actually sits in my environment in the &lt;code&gt;.retired&lt;/code&gt; state: &lt;code&gt;~/Library/LaunchAgents/com.shun.zenn-daily.plist.retired&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;?xml version="1.0" encoding="UTF-8"?&amp;gt;&lt;/span&gt;
&lt;span class="cp"&gt;&amp;lt;!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd"&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;plist&lt;/span&gt; &lt;span class="na"&gt;version=&lt;/span&gt;&lt;span class="s"&gt;"1.0"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;dict&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;Label&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;com.shun.zenn-daily&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;ProgramArguments&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;array&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;/bin/bash&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;~/.discord/run-and-notify.sh&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;zenn&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;Zenn日次公開&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;/bin/bash&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;~/.claude/scripts/zenn-daily-publish.sh&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;apply&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/array&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;StartCalendarInterval&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;array&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;dict&amp;gt;&amp;lt;key&amp;gt;&lt;/span&gt;Hour&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;7&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&amp;lt;key&amp;gt;&lt;/span&gt;Minute&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;30&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;dict&amp;gt;&amp;lt;key&amp;gt;&lt;/span&gt;Hour&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;10&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&amp;lt;key&amp;gt;&lt;/span&gt;Minute&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;0&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;dict&amp;gt;&amp;lt;key&amp;gt;&lt;/span&gt;Hour&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;14&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&amp;lt;key&amp;gt;&lt;/span&gt;Minute&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;0&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;dict&amp;gt;&amp;lt;key&amp;gt;&lt;/span&gt;Hour&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;19&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&amp;lt;key&amp;gt;&lt;/span&gt;Minute&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;integer&amp;gt;&lt;/span&gt;0&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/array&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;RunAtLoad&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;false/&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;ProcessType&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;Background&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/plist&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The file name is &lt;code&gt;com.shun.zenn-daily.plist.retired&lt;/code&gt;, but the &lt;code&gt;Label&lt;/code&gt; inside is still &lt;code&gt;com.shun.zenn-daily&lt;/code&gt;. launchd already read this file at login and registered it in its internal table under the label &lt;code&gt;com.shun.zenn-daily&lt;/code&gt;. Renaming the file afterwards does nothing to that table entry.&lt;/p&gt;

&lt;p&gt;This job has four times in &lt;code&gt;StartCalendarInterval&lt;/code&gt;: 7:30, 10:00, 14:00, and 19:00. After the rename, launchd keeps calling &lt;code&gt;~/.claude/scripts/zenn-daily-publish.sh apply&lt;/code&gt; at every one of those times. Check the logs and you'll find execution traces piling up in &lt;code&gt;~/.claude/logs/zenn-daily.out.log&lt;/code&gt; and &lt;code&gt;~/.claude/logs/zenn-daily.err.log&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  launchd's ID is the Label, not the path
&lt;/h3&gt;

&lt;p&gt;macOS launchd is designed differently from Linux systemd. systemd ties the unit file path tightly to the service name, whereas launchd treats the plist path as nothing more than "where to load from the first time." Everything after that is managed entirely by the &lt;code&gt;Label&lt;/code&gt; value.&lt;/p&gt;

&lt;p&gt;There's a rational design reason for this. launchd sits close to the core of macOS, and it needs to detect conflicts when jobs with the same Label are scattered across multiple locations (&lt;code&gt;~/Library/LaunchAgents/&lt;/code&gt;, &lt;code&gt;/Library/LaunchAgents/&lt;/code&gt;, &lt;code&gt;/Library/LaunchDaemons/&lt;/code&gt;). The flip side: when a file disappears from &lt;code&gt;LaunchAgents/&lt;/code&gt;, there is no mechanism that automatically removes the already-loaded entry.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why it keeps running after the rename
&lt;/h3&gt;

&lt;p&gt;macOS watches &lt;code&gt;~/Library/LaunchAgents/&lt;/code&gt; &lt;strong&gt;as a directory&lt;/strong&gt;. When a file is added, launchd reads and loads it; when one is removed, that triggers an unload. But a rename is reported as "deleted under the old name, added under the new name."&lt;/p&gt;

&lt;p&gt;Here's the catch. Even if &lt;code&gt;com.shun.zenn-daily.plist&lt;/code&gt; is deleted, what launchd would unload is "the job whose Label was written in that file." On macOS 13 and later, when deciding whether &lt;code&gt;com.shun.zenn-daily.plist.retired&lt;/code&gt; should be treated as a plist, &lt;strong&gt;files whose extension isn't &lt;code&gt;.plist&lt;/code&gt; are excluded from watching&lt;/strong&gt; and ignored. In other words:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;com.shun.zenn-daily.plist&lt;/code&gt; → delete event → &lt;strong&gt;should be unloaded in principle&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;But within an already-loaded session, there are cases where auto-unload via filesystem watching doesn't take effect&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You may get lucky and have it unloaded at the moment of the rename, but &lt;strong&gt;across a login session boundary it will definitely still be there&lt;/strong&gt;. If you leave the file in the &lt;code&gt;.retired&lt;/code&gt; state and reboot, it never gets loaded in the first place, so no problem appears. But on a Mac that has been running for a long time without a reboot, a rename alone means the job absolutely keeps running. As your automation grows, that Mac reboots less and less often. That's why "it's supposed to be retired but it's running" is such an easy trap to fall into.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the damage looks like
&lt;/h3&gt;

&lt;p&gt;What actually happened in my environment: "a lane that was supposed to be retired kept running on a different scheduler, and the day after I moved its working folder, it started firing blanks." After the folder move, execution simply fails, so &lt;code&gt;exit 1&lt;/code&gt; piles up in the logs — but from launchd's point of view it's just "the job ran (and failed)," so nothing gets unloaded. A pattern of quiet failures that keep going.&lt;/p&gt;

&lt;p&gt;When you operate a fleet of 160+ jobs, several of these "ghost jobs" can be mixed in at once. Unless you periodically take inventory with &lt;code&gt;launchctl list | grep com.lily&lt;/code&gt;, unintentionally leftover jobs keep consuming resources that other jobs need (AVD locks, browser slots, quota).&lt;/p&gt;




&lt;h2&gt;
  
  
  The overall flow
&lt;/h2&gt;

&lt;p&gt;Retiring a launchd job correctly takes three steps. File operations come afterwards — &lt;strong&gt;getting &lt;code&gt;bootout&lt;/code&gt; through comes first&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌─────────────────────────────────────────────────┐
│  plist.retired にリネームした状態（NG）            │
│                                                 │
│  ~/Library/LaunchAgents/                        │
│    com.shun.zenn-daily.plist.retired  ← ファイル名変更済み │
│                                                 │
│  launchd 内部テーブル                             │
│    Label: com.shun.zenn-daily  ← まだ生きている   │
│      → 7:30 / 10:00 / 14:00 / 19:00 に発火      │
└─────────────────────────────────────────────────┘

            ↓ bootout を実行する

┌─────────────────────────────────────────────────┐
│  正しい退役後の状態（OK）                           │
│                                                 │
│  ~/Library/LaunchAgents/                        │
│    com.shun.zenn-daily.plist.retired            │
│                                                 │
│  launchd 内部テーブル                             │
│    Label: com.shun.zenn-daily  → エントリなし     │
│      → 発火しない                                 │
└─────────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 1: Unload with bootout
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;launchctl bootout gui/&lt;span class="nv"&gt;$UID&lt;/span&gt;/com.shun.zenn-daily
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;gui/$UID&lt;/code&gt; is the login session domain. &lt;code&gt;$UID&lt;/code&gt; expands to the current user's UID as-is. If you get a &lt;code&gt;No such process&lt;/code&gt; error, ignore it and continue. That just means you called &lt;code&gt;bootout&lt;/code&gt; on a job that was already unloaded — not an anomaly.&lt;/p&gt;

&lt;p&gt;There's an &lt;strong&gt;important pitfall&lt;/strong&gt;. If you &lt;code&gt;bootout&lt;/code&gt; a running job and immediately try to &lt;code&gt;bootstrap&lt;/code&gt; (reload) it, it can fail with &lt;code&gt;5: Input/output error&lt;/code&gt;. This is a race condition where the next command runs before the kernel has finished tearing down the service. In my environment, &lt;code&gt;com.lily.threadspilot.engage&lt;/code&gt; vanished once because of this. When you do need to reload, insert a script that retries up to 15 times at 1-second intervals, or &lt;code&gt;sleep 2&lt;/code&gt; before &lt;code&gt;bootstrap&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The goal here is only to "stop," so no &lt;code&gt;bootstrap&lt;/code&gt; is needed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Confirm it's gone with launchctl list
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;launchctl list | &lt;span class="nb"&gt;grep &lt;/span&gt;zenn-daily
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the output is empty, the entry is gone from launchd's table. The job will not fire.&lt;/p&gt;

&lt;p&gt;However, &lt;strong&gt;not appearing in &lt;code&gt;launchctl list&lt;/code&gt; is not a sufficient condition for "retirement complete."&lt;/strong&gt; The reverse problem matters more: even if a job appears in &lt;code&gt;launchctl list&lt;/code&gt;, that only means it's "registered" — not that it's "loaded with the latest plist definition."&lt;/p&gt;

&lt;p&gt;I fell into this trap when I bulk-injected a quota-guard wrapper into 44 jobs. In &lt;code&gt;launchctl list&lt;/code&gt;, every job looked registered, but when I actually checked the contents with &lt;code&gt;launchctl print&lt;/code&gt;, several jobs still had the old &lt;code&gt;ProgramArguments&lt;/code&gt;. Only the live definition returned by &lt;code&gt;launchctl print&lt;/code&gt; is trustworthy evidence of state.&lt;/p&gt;

&lt;p&gt;For retirement checks, disappearing from &lt;code&gt;list&lt;/code&gt; is enough; but for post-reload checks, make it a habit to always use &lt;code&gt;print&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 退役確認用（消えていればOK）&lt;/span&gt;
launchctl list | &lt;span class="nb"&gt;grep &lt;/span&gt;com.shun.zenn-daily

&lt;span class="c"&gt;# 再ロード後の定義確認用（新しいProgramArgumentsが反映されているか）&lt;/span&gt;
launchctl print gui/&lt;span class="nv"&gt;$UID&lt;/span&gt;/com.shun.zenn-daily
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 3: Tidy up the file (optional)
&lt;/h3&gt;

&lt;p&gt;Once &lt;code&gt;bootout&lt;/code&gt; goes through, the job won't fire even if the file remains in &lt;code&gt;LaunchAgents/&lt;/code&gt;. And since &lt;code&gt;.plist.retired&lt;/code&gt; files are outside launchd's watch scope, they won't be auto-loaded at the next login or reboot either.&lt;/p&gt;

&lt;p&gt;Leaving it like this causes no operational problem, but it makes the fleet harder to read. My policy is to move retired plists to a backup directory.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/content/launchagents-backup-&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%Y%m%d&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;mv&lt;/span&gt; ~/Library/LaunchAgents/com.shun.zenn-daily.plist.retired &lt;span class="se"&gt;\&lt;/span&gt;
   ~/content/launchagents-backup-&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%Y%m%d&lt;span class="si"&gt;)&lt;/span&gt;/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After moving, confirm there are no leftovers with &lt;code&gt;ls ~/Library/LaunchAgents/ | grep zenn&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Putting it together: the complete retirement command sequence
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. アンロード（No such process は無視してよい）&lt;/span&gt;
launchctl bootout gui/&lt;span class="nv"&gt;$UID&lt;/span&gt;/com.shun.zenn-daily

&lt;span class="c"&gt;# 2. テーブルから消えたか確認&lt;/span&gt;
launchctl list | &lt;span class="nb"&gt;grep &lt;/span&gt;zenn-daily
&lt;span class="c"&gt;# 出力が空であればOK&lt;/span&gt;

&lt;span class="c"&gt;# 3. plistをバックアップへ移動&lt;/span&gt;
&lt;span class="nv"&gt;BACKUP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;~/content/launchagents-backup-&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%Y%m%d&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BACKUP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;mv&lt;/span&gt; ~/Library/LaunchAgents/com.shun.zenn-daily.plist.retired &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BACKUP&lt;/span&gt;&lt;span class="s2"&gt;/"&lt;/span&gt;

&lt;span class="c"&gt;# 4. 移動後の残骸確認&lt;/span&gt;
&lt;span class="nb"&gt;ls&lt;/span&gt; ~/Library/LaunchAgents/ | &lt;span class="nb"&gt;grep &lt;/span&gt;zenn
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These four steps are the answer to "what should I actually have done when I renamed it to &lt;code&gt;.plist.retired&lt;/code&gt;?"&lt;/p&gt;

&lt;h3&gt;
  
  
  Why the &lt;code&gt;.retired&lt;/code&gt; rename became a habit
&lt;/h3&gt;

&lt;p&gt;The macOS convention of renaming to &lt;code&gt;.plist.disabled&lt;/code&gt; or &lt;code&gt;.plist.retired&lt;/code&gt; exists because it's "easy to bring back later." Deleting outright makes it expensive to reconstruct the original plist. Keeping it in the same place under a &lt;code&gt;.retired&lt;/code&gt; name as a backup is a reasonable idea.&lt;/p&gt;

&lt;p&gt;The problem is that, unless you know how launchd behaves, it's hard to see that a rename is a "declaration of intent to retire," not the "execution of retirement." If you managed plists under Git, &lt;code&gt;git log&lt;/code&gt; would give you the change history and the backup rename would be unnecessary — but almost nobody puts &lt;code&gt;~/Library/LaunchAgents/&lt;/code&gt; under Git. So the rename technique lives on.&lt;/p&gt;

&lt;p&gt;To state the procedure precisely: &lt;strong&gt;the rename is the backup. &lt;code&gt;bootout&lt;/code&gt; is the stop. They are two separate operations.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation details
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Adding just 4 elements to &lt;code&gt;ProgramArguments&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;bootout → bootstrap&lt;/code&gt; cycle is the same whether you're "stopping" a job or "switching it to run through a wrapper." In August 2026, when I bulk-inserted a quota guard into a fleet of 160+ jobs, this was the entire change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before&lt;/strong&gt; (the actual structure of &lt;code&gt;com.shun.zenn-daily&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;ProgramArguments&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;array&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;/bin/bash&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;~/.discord/run-and-notify.sh&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;zenn&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;Zenn日次公開&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;/bin/bash&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;~/.claude/scripts/zenn-daily-publish.sh&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;apply&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/array&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;After&lt;/strong&gt; (only 4 elements added at the front):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;ProgramArguments&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;array&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;~/.claude/scripts/quota-guard.sh&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;--job&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;com.shun.zenn-daily&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;--&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;/bin/bash&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;~/.discord/run-and-notify.sh&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;zenn&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;Zenn日次公開&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;/bin/bash&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;~/.claude/scripts/zenn-daily-publish.sh&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;apply&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/array&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Touch only &lt;code&gt;ProgramArguments&lt;/code&gt;.&lt;/strong&gt; Not a single key among &lt;code&gt;StartCalendarInterval&lt;/code&gt; (the 4 slots at 7:30, 10:00, 14:00, 19:00), &lt;code&gt;ProcessType&lt;/code&gt; (Background), &lt;code&gt;RunAtLoad&lt;/code&gt; (false), &lt;code&gt;StandardOutPath&lt;/code&gt;, or &lt;code&gt;StandardErrorPath&lt;/code&gt; changes. If you change the schedule and the output destinations at the same time, you can no longer tell "is this a wrapper problem or a timing problem?" It's a design choice that narrows things to a single variable when something breaks.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why write the semantic diff check in Python
&lt;/h3&gt;

&lt;p&gt;Run XML through a text diff and you get piles of line differences from nothing but attribute order and whitespace. Eyeballing "did any key I didn't want to change get changed?" falls apart at 44 files. The right approach is a key-by-key comparison with plistlib (Python standard library).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;plistlib&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;verify_minimal_change&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;original&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;modified&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;orig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;plistlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;original&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_bytes&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;mod&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;plistlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;modified&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_bytes&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

    &lt;span class="c1"&gt;# ProgramArguments以外のキーが変わっていないか
&lt;/span&gt;    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;orig&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ProgramArguments&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;orig&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;mod&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;意図しないキー変更: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="c1"&gt;# 既存コマンドのsuffixが保持されているか
&lt;/span&gt;    &lt;span class="n"&gt;orig_args&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;orig&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ProgramArguments&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;new_args&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mod&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ProgramArguments&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;new_args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orig_args&lt;/span&gt;&lt;span class="p"&gt;):]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;orig_args&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; \
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;既存ProgramArgumentsのsuffixが変わっている&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run this verification across every target to mechanically confirm "only &lt;code&gt;ProgramArguments&lt;/code&gt; changed, and the existing command suffix matches exactly" — only then proceed to &lt;code&gt;plutil -lint&lt;/code&gt; → &lt;code&gt;bootout&lt;/code&gt; → &lt;code&gt;bootstrap&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The &lt;code&gt;bootout → bootstrap&lt;/code&gt; retry pattern
&lt;/h3&gt;

&lt;p&gt;Retirement (just stopping) is complete with &lt;code&gt;bootout&lt;/code&gt;, but when a reload is needed, use this shape.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;reload_job&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;label&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;plist_path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$2&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;domain&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"gui/&lt;/span&gt;&lt;span class="nv"&gt;$UID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

  &lt;span class="c"&gt;# bootout（No such process は正常・無視してよい）&lt;/span&gt;
  launchctl bootout &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;domain&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;label&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;

  &lt;span class="c"&gt;# bootstrap は最大15回・1秒間隔でリトライ&lt;/span&gt;
  &lt;span class="c"&gt;# カーネル側のサービス消滅完了を待つため&lt;/span&gt;
  &lt;span class="nb"&gt;local &lt;/span&gt;&lt;span class="nv"&gt;max_retry&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;15
  &lt;span class="k"&gt;for &lt;/span&gt;i &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;seq &lt;/span&gt;1 &lt;span class="nv"&gt;$max_retry&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
    if &lt;/span&gt;launchctl bootstrap &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$domain&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$plist_path&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
      &lt;/span&gt;&lt;span class="nb"&gt;break
    &lt;/span&gt;&lt;span class="k"&gt;fi
    if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$i&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-eq&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$max_retry&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
      &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"RELOAD FAILED: &lt;/span&gt;&lt;span class="nv"&gt;$label&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
      &lt;span class="k"&gt;return &lt;/span&gt;1
    &lt;span class="k"&gt;fi
    &lt;/span&gt;&lt;span class="nb"&gt;sleep &lt;/span&gt;1
  &lt;span class="k"&gt;done&lt;/span&gt;

  &lt;span class="c"&gt;# live定義で検証（listではなくprintを使う）&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;launchctl print &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;domain&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;label&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s2"&gt;"quota-guard"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"RELOAD OK: &lt;/span&gt;&lt;span class="nv"&gt;$label&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="k"&gt;else
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"RELOAD WARN: live定義にラッパが見えない &lt;/span&gt;&lt;span class="nv"&gt;$label&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;2
    &lt;span class="k"&gt;return &lt;/span&gt;1
  &lt;span class="k"&gt;fi&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key to this pattern is its asymmetry. &lt;strong&gt;&lt;code&gt;bootout&lt;/code&gt; errors can be ignored; &lt;code&gt;bootstrap&lt;/code&gt; errors must not be.&lt;/strong&gt; &lt;code&gt;bootout&lt;/code&gt; is idempotent (an operation toward a state that's already "not there"), but if &lt;code&gt;bootstrap&lt;/code&gt; fails, the job is left unloaded and gone. Swallow that with &lt;code&gt;|| true&lt;/code&gt; and you'll land in the next trap.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;launchctl print&lt;/code&gt; is the only way to see the real state
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 退役確認（消えていればOK）&lt;/span&gt;
launchctl list | &lt;span class="nb"&gt;grep &lt;/span&gt;com.shun.zenn-daily
&lt;span class="c"&gt;# → 出力が空であればアンロード済み&lt;/span&gt;

&lt;span class="c"&gt;# 再ロード後の定義確認（新しいProgramArgumentsが反映されているか）&lt;/span&gt;
launchctl print gui/&lt;span class="nv"&gt;$UID&lt;/span&gt;/com.shun.zenn-daily | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"program ="&lt;/span&gt;
&lt;span class="c"&gt;# → program = /path/to/quota-guard.sh  であればラッパが効いている&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output of &lt;code&gt;launchctl print&lt;/code&gt; contains a line &lt;code&gt;program =&lt;/code&gt; with the path of the binary that actually gets launched. If this still shows the old &lt;code&gt;claude&lt;/code&gt; binary, you're in the state of "I edited the file but the reload didn't take." Even if the string &lt;code&gt;quota-guard&lt;/code&gt; shows up in the logs, unless you check this line, you've only &lt;em&gt;convinced yourself&lt;/em&gt; it went through.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where I got stuck
&lt;/h2&gt;

&lt;p&gt;When managing 160+ jobs, three patterns overlap: "I stopped it but it's running," "I changed it but it didn't take," and "it's running but producing zero results." Here are four cases where I actually got stuck, in symptom → cause → fix order.&lt;/p&gt;

&lt;h3&gt;
  
  
  ① A "retired" job quietly died the day after a folder move
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; The three ai-portraits series jobs — blur, partial, and swim — all started failing together with &lt;code&gt;0/2&lt;/code&gt; (zero targets) from the morning after I moved their working folder. The logs just showed &lt;code&gt;exit 1&lt;/code&gt; stacking up. From launchd's point of view it was only "the job ran (and failed)," so nothing got unloaded.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause:&lt;/strong&gt; I'd assumed the &lt;code&gt;.plist.retired&lt;/code&gt; rename alone had stopped them, and never ran &lt;code&gt;launchctl bootout&lt;/code&gt;. The morning after the rename on a long-running Mac, no session boundary had been crossed, so the jobs were still alive. Since I'd already moved the working folder the day before, all three jobs immediately hit &lt;code&gt;exit 1&lt;/code&gt; with "folder missing."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Just keep the order — get &lt;code&gt;bootout&lt;/code&gt; through &lt;em&gt;before&lt;/em&gt; moving the folder.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# フォルダを移動する前に必ずやる&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;label &lt;span class="k"&gt;in &lt;/span&gt;com.lily.ai-portraits-blur com.lily.ai-portraits-partial com.lily.ai-portraits-swim&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;launchctl bootout gui/&lt;span class="nv"&gt;$UID&lt;/span&gt;/&lt;span class="nv"&gt;$label&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
&lt;/span&gt;&lt;span class="k"&gt;done&lt;/span&gt;

&lt;span class="c"&gt;# テーブルから消えたか全件確認&lt;/span&gt;
launchctl list | &lt;span class="nb"&gt;grep &lt;/span&gt;ai-portraits
&lt;span class="c"&gt;# 出力が空になってからフォルダを移動する&lt;/span&gt;

&lt;span class="c"&gt;# これが安全な順序&lt;/span&gt;
&lt;span class="nb"&gt;mv&lt;/span&gt; ~/dev/ai-portraits/work ~/dev/ai-portraits/work-archived
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;I had the order backwards.&lt;/strong&gt; I pulled out the infrastructure prerequisites before declaring the intent to retire.&lt;/p&gt;

&lt;h3&gt;
  
  
  ② &lt;code&gt;bootstrap&lt;/code&gt; returned &lt;code&gt;5: I/O error&lt;/code&gt; and erased the job
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; After a rewiring, &lt;code&gt;com.lily.threadspilot.engage&lt;/code&gt; had vanished from &lt;code&gt;launchctl list&lt;/code&gt;. No firing traces in the script logs either. It was left unloaded and gone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause:&lt;/strong&gt; A race condition from calling &lt;code&gt;bootstrap&lt;/code&gt; immediately after &lt;code&gt;bootout&lt;/code&gt; on a running job. &lt;code&gt;bootstrap&lt;/code&gt; ran before the kernel had finished tearing down the service, and returned &lt;code&gt;5: Input/output error&lt;/code&gt;. The reload script swallowed that error with &lt;code&gt;2&amp;gt;/dev/null&lt;/code&gt; and moved on to the next job as if it had succeeded. The result: only &lt;code&gt;threadspilot.engage&lt;/code&gt; was left unloaded, with its registration gone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Add the retry pattern shown above. Pair retries (up to 15 times at 1-second intervals) with a live-definition check via &lt;code&gt;launchctl print&lt;/code&gt;. You can only say "&lt;code&gt;bootout&lt;/code&gt; succeeded and &lt;code&gt;bootstrap&lt;/code&gt; succeeded" once you've actually confirmed the program path in &lt;code&gt;launchctl print&lt;/code&gt;. Discard errors only on the &lt;code&gt;bootout&lt;/code&gt; side.&lt;/p&gt;

&lt;h3&gt;
  
  
  ③ I trusted &lt;code&gt;launchctl list&lt;/code&gt; and declared all 44 jobs "done"
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; The day after bulk-inserting the quota guard into 44 jobs, I discovered some jobs were still calling the bare &lt;code&gt;claude&lt;/code&gt; binary directly, bypassing the guard entirely. Quota usage was above expectations, and I only noticed after investigating.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause:&lt;/strong&gt; The reload script confirmed "registered" via the output of &lt;code&gt;launchctl list | grep $label&lt;/code&gt; and then printed &lt;code&gt;RELOAD OK&lt;/code&gt;. But showing up in &lt;code&gt;list&lt;/code&gt; is evidence of "registered," not evidence of "running with the new plist definition." Several jobs had failed &lt;code&gt;bootstrap&lt;/code&gt; with &lt;code&gt;5: I/O error&lt;/code&gt;, and their definitions from the old session were still there, still showing in &lt;code&gt;list&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Change the verification command from &lt;code&gt;list&lt;/code&gt; to &lt;code&gt;print&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# NG: listに出ることしか確認できない（旧い定義のままでも出る）&lt;/span&gt;
launchctl list | &lt;span class="nb"&gt;grep &lt;/span&gt;com.lily.some-job

&lt;span class="c"&gt;# OK: live定義のprogramを確認する&lt;/span&gt;
launchctl print gui/&lt;span class="nv"&gt;$UID&lt;/span&gt;/com.lily.some-job &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"^  program ="&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s2"&gt;"quota-guard"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"OK"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"NG: ラッパが入っていない"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I rewrote the script to run &lt;code&gt;print&lt;/code&gt; on all 44 jobs and mechanically check whether the wrapper appears in &lt;code&gt;program =&lt;/code&gt;, then re-ran &lt;code&gt;bootout → bootstrap&lt;/code&gt; on the 6 jobs that still had old definitions.&lt;/p&gt;

&lt;h3&gt;
  
  
  ④ A script was unconditionally overwriting the &lt;code&gt;CLAUDE_BIN&lt;/code&gt; environment variable
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Symptom:&lt;/strong&gt; The &lt;code&gt;ig-autoreply-ig-2&lt;/code&gt; logs were full of "DM判定失敗: claude exit 1". The quota guard was supposed to be passing &lt;code&gt;CLAUDE_BIN=~/.claude/scripts/quota-guard.sh&lt;/code&gt; to child processes, yet what was actually being called was the bare &lt;code&gt;~/.local/bin/claude&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause:&lt;/strong&gt; Opening &lt;code&gt;~/dev/social-autolike/scripts/run-ig-autoreply.sh&lt;/code&gt;, the top of the file said this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;CLAUDE_BIN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;~/.local/bin/claude
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;An unconditional overwrite.&lt;/strong&gt; Even if the launchd plist passes the wrapper path, the moment the child shell executes this line, the guard's path is gone. The same pattern existed in &lt;code&gt;run-comment-reply.sh&lt;/code&gt;, &lt;code&gt;run-rewrite.sh&lt;/code&gt;, and &lt;code&gt;run-editor.sh&lt;/code&gt;. Four scripts in total. Wiring doesn't end at the plist. You have to check whether anyone along the path is overwriting unconditionally.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; Change all four to the &lt;code&gt;:-&lt;/code&gt; form.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 変更前（無条件上書き・ガードを殺す）&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;CLAUDE_BIN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;~/.local/bin/claude

&lt;span class="c"&gt;# 変更後（既存値がある場合は尊重する）&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;CLAUDE_BIN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CLAUDE_BIN&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="p"&gt;~/.local/bin/claude&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the fix, I added a test case that asserts, with an exact match, that "this assignment line is written in the &lt;code&gt;:-&lt;/code&gt; form." A test that only checks "does the string &lt;code&gt;CLAUDE_BIN&lt;/code&gt; exist?" passes even when an old commented-out line is present. The existing test was exactly like that, and I missed it once. Instead of confirming existence with grep, you need to verify the form of the assignment line itself.&lt;/p&gt;




&lt;h2&gt;
  
  
  The structure of accumulating silent failures
&lt;/h2&gt;

&lt;p&gt;What the four cases have in common is a structure where "the error is invisible."&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Rename to &lt;code&gt;.retired&lt;/code&gt; and forget &lt;code&gt;bootout&lt;/code&gt; → the job keeps running, but nothing appears in launchd's error logs&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;bootstrap&lt;/code&gt; fails with &lt;code&gt;5: I/O error&lt;/code&gt; → if the script discards it with &lt;code&gt;2&amp;gt;/dev/null&lt;/code&gt;, silence&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;launchctl list&lt;/code&gt; shows jobs with old definitions as "registered" → looks normal on the surface, stale on the inside&lt;/li&gt;
&lt;li&gt;Environment variable overwrite → you think the guard is in place, but it's bypassed. The job itself runs, so the exit code is normal&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;At a scale of 160 jobs, "running" and "running as intended" are different states.&lt;/strong&gt; Feeling reassured because you see a log of the job running means your verification isn't deep enough.&lt;/p&gt;

&lt;p&gt;To detect this problem regularly, I run the following inventory locally once a month.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# launchdに登録されている自分のジョブ一覧&lt;/span&gt;
launchctl list | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'com\.lily\|com\.shun'&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $3}'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/loaded.txt

&lt;span class="c"&gt;# LaunchAgentsにplistとして存在するジョブのLabel一覧&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;f &lt;span class="k"&gt;in&lt;/span&gt; ~/Library/LaunchAgents/com.&lt;span class="o"&gt;{&lt;/span&gt;lily,shun&lt;span class="o"&gt;}&lt;/span&gt;.&lt;span class="k"&gt;*&lt;/span&gt;.plist&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;defaults &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; Label 2&amp;gt;/dev/null
&lt;span class="k"&gt;done&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/files.txt

&lt;span class="c"&gt;# 差分を見る&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"=== ファイルがないのにloaded ==="&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;comm&lt;/span&gt; &lt;span class="nt"&gt;-23&lt;/span&gt; /tmp/loaded.txt /tmp/files.txt
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"=== plistがあるのにunloaded ==="&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;comm&lt;/span&gt; &lt;span class="nt"&gt;-13&lt;/span&gt; /tmp/loaded.txt /tmp/files.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a line shows up under "loaded but no file," that's a ghost job. Jobs you thought you'd stopped with a &lt;code&gt;.retired&lt;/code&gt; rename back in the day, still surviving because no session boundary was crossed, appear here.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;com.shun.zenn-daily.plist.retired&lt;/code&gt; also shows up instantly if you run this inventory. Because the file's extension isn't &lt;code&gt;.plist&lt;/code&gt;, it won't appear in &lt;code&gt;/tmp/files.txt&lt;/code&gt;, but if &lt;code&gt;bootout&lt;/code&gt; was never run, it &lt;em&gt;will&lt;/em&gt; appear in &lt;code&gt;/tmp/loaded.txt&lt;/code&gt;. That gap reveals the existence of a ghost job.&lt;/p&gt;

&lt;p&gt;Changing a file's name and removing an entry from launchd's table are separate operations. If you have a mechanism that makes that gap visible, any retirement you forgot to finish will be caught in next month's inventory, without fail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stumbling points
&lt;/h2&gt;

&lt;p&gt;The four cases where I got stuck are above. Here I'll list, comprehensively, the points that are "easy to do but rarely said out loud." Only things I actually hit while running a 160+ job fleet.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Skipped &lt;code&gt;plutil -lint&lt;/code&gt; before &lt;code&gt;bootout → bootstrap&lt;/code&gt;.&lt;/strong&gt; If the plist has a syntax error, &lt;code&gt;bootstrap&lt;/code&gt; looks successful and the job even shows in &lt;code&gt;launchctl list&lt;/code&gt;, but it never fires — not once. macOS silently ignores broken plists. Even in &lt;code&gt;launchctl print&lt;/code&gt; it stays at &lt;code&gt;state = waiting&lt;/code&gt; and doesn't run when the time comes. Twice I spent an hour investigating with no idea of the cause, only to finally run &lt;code&gt;plutil -lint com.lily.something.plist&lt;/code&gt; and see the syntax error. &lt;strong&gt;Whenever you touch a file, always go &lt;code&gt;plutil -lint&lt;/code&gt; → &lt;code&gt;bootout&lt;/code&gt; → &lt;code&gt;bootstrap&lt;/code&gt; → &lt;code&gt;launchctl print&lt;/code&gt;, in that order.&lt;/strong&gt; &lt;code&gt;plutil -lint&lt;/code&gt; alone comes before every other step.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Wrote a tilde (&lt;code&gt;~&lt;/code&gt;) in a path inside the plist.&lt;/strong&gt; launchd is not a shell, so it does not expand &lt;code&gt;~&lt;/code&gt; to &lt;code&gt;/Users/yourname&lt;/code&gt;. This is the cause of the vast majority of "the job fires but keeps ending with &lt;code&gt;exit 127: command not found&lt;/code&gt;." If you look at the pre-change &lt;code&gt;ProgramArguments&lt;/code&gt; in &lt;code&gt;~/Library/LaunchAgents/com.shun.zenn-daily.plist.retired&lt;/code&gt;, it doesn't actually say &lt;code&gt;~/.discord/run-and-notify.sh&lt;/code&gt;. &lt;code&gt;cat&lt;/code&gt; the real file and you'll find absolute paths. &lt;strong&gt;Everything inside a plist is an absolute path.&lt;/strong&gt; Be strict about the split: tilde notation in articles and explanations, absolute paths in the real file.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Forgot to include the three keys &lt;code&gt;ProcessType&lt;/code&gt;, &lt;code&gt;Nice&lt;/code&gt;, and &lt;code&gt;LowPriorityIO&lt;/code&gt; in a new plist.&lt;/strong&gt; These three keys are what let macOS lower a job's priority.&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;  &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;ProcessType&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;Background&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;Nice&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;integer&amp;gt;&lt;/span&gt;10&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;LowPriorityIO&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;true/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Omit them and a job that's supposed to run in the background executes at the same priority as the foreground. At 160-job scale, the Mac gets noticeably sluggish at daytime peak. When I wired browser-slot into 23 jobs in August 2026, I found these three keys missing from 18 of them. Always include them in your template when writing a new plist.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Left &lt;code&gt;StartCalendarInterval&lt;/code&gt; as a single dict and placed the firing time during hours when the Mac sleeps.&lt;/strong&gt; If the Mac is asleep at a &lt;code&gt;StartCalendarInterval&lt;/code&gt; firing time, macOS &lt;strong&gt;drops that firing entirely.&lt;/strong&gt; cron catches up on accumulated runs at startup; launchd does not. If you placed a once-a-day job in the middle of the night, on days when the screen isn't on it may never run at all. &lt;strong&gt;Convert single-time jobs to a multi-slot dict array.&lt;/strong&gt; The reason &lt;code&gt;com.shun.zenn-daily&lt;/code&gt; has 4 slots — 7:30, 10:00, 14:00, 19:00 — is so that if one slot is lost to sleep, the others pick it up.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Switched to multiple slots without adding an idempotency guard, and posted 4 times in one day.&lt;/strong&gt; Multi-slot is a "any one getting through is enough" design. Unless you simultaneously add a mechanism to skip the rest once the first slot succeeds (e.g., checking a state file for the day), all 4 slots go through and it runs 4 times. Changing &lt;code&gt;StartCalendarInterval&lt;/code&gt; from single to multiple and adding an idempotency guard are a set. Do only one and you get real damage.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Designed the schedule interval based on "how long one job takes on its own."&lt;/strong&gt; The &lt;code&gt;tiktok-autopost&lt;/code&gt; case is a textbook example. I'd set the comment job to a 15-minute interval, but the job for another account sharing the exclusive lock (&lt;code&gt;tiktok-avd.lock&lt;/code&gt;) uses the same lock. Fire a job that takes about 10 minutes per run at 15-minute intervals across 2 accounts combined (meaning the other one arrives every 7.5 minutes), and the lock is permanently occupied. Measured from July 21 to August 14, 2026: &lt;code&gt;renappi&lt;/code&gt; launched 617 times with 255 &lt;code&gt;exit 1&lt;/code&gt;s, &lt;code&gt;bokuwalily&lt;/code&gt; launched 784 times with 458 &lt;code&gt;exit 1&lt;/code&gt;s — all with zero results, while the follow lane hadn't updated its log in 3 weeks. &lt;strong&gt;Calculate intervals from the total occupancy of every job sharing the exclusive resource.&lt;/strong&gt; A schedule designed around individual jobs' convenience will inevitably crush other lanes as scale grows.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Didn't verify a zero-result job's reason for existing before lowering its frequency.&lt;/strong&gt; In the tiktok case above, I'd run the comment job 600+ times with the account still configured at &lt;code&gt;commentActions=0&lt;/code&gt;. That a job is running (exit code 0) and that a job is producing its intended result are two different things. Before adjusting frequency, check "has this job produced even a single result today?"&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Didn't set &lt;code&gt;EnvironmentVariables&lt;/code&gt;, so PATH was missing.&lt;/strong&gt; The default PATH for jobs launched by launchd is roughly &lt;code&gt;/usr/bin:/bin:/usr/sbin:/sbin&lt;/code&gt;. Homebrew binaries (&lt;code&gt;/opt/homebrew/bin&lt;/code&gt;) and nvm's Node (&lt;code&gt;~/.nvm/versions/node/...&lt;/code&gt;) aren't in it. If you get &lt;code&gt;command not found&lt;/code&gt; but the same command works when run manually from the terminal, this is it. Always add this to the plist:&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;  &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;EnvironmentVariables&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;dict&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;key&amp;gt;&lt;/span&gt;PATH&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;string&amp;gt;&lt;/span&gt;/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Used &lt;code&gt;system/&lt;/code&gt; as the domain for &lt;code&gt;launchctl bootout&lt;/code&gt;.&lt;/strong&gt; User-level LaunchAgents live in the &lt;code&gt;gui/$UID&lt;/code&gt; domain. &lt;code&gt;system/&lt;/code&gt; is the LaunchDaemons domain, managed by root. Running &lt;code&gt;bootout&lt;/code&gt; against &lt;code&gt;system/com.shun.zenn-daily&lt;/code&gt; just ends with &lt;code&gt;No such process&lt;/code&gt; and the job doesn't stop. A plist placed in &lt;code&gt;~/Library/LaunchAgents/&lt;/code&gt; always uses &lt;code&gt;gui/$UID&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Passed a relative path to the wrapper script's &lt;code&gt;--log&lt;/code&gt; argument.&lt;/strong&gt; Wrappers like &lt;code&gt;run-with-retry.sh&lt;/code&gt; may &lt;code&gt;cd&lt;/code&gt; internally. If you pass a relative path to &lt;code&gt;--log&lt;/code&gt;, the wrapper expands it after changing directory, so the log gets written somewhere unexpected — or the file isn't created and the output is discarded. Always pass &lt;code&gt;--log&lt;/code&gt; an absolute path.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Left a new job at &lt;code&gt;RunAtLoad: true&lt;/code&gt; while also setting &lt;code&gt;StartCalendarInterval&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;RunAtLoad: true&lt;/code&gt; also runs once the moment the plist is loaded (right after &lt;code&gt;bootstrap&lt;/code&gt;). Combined with &lt;code&gt;StartCalendarInterval&lt;/code&gt;, you get one run right after load plus one at each scheduled time. A case had crept into the fleet where &lt;code&gt;RunAtLoad&lt;/code&gt; was temporarily set to &lt;code&gt;true&lt;/code&gt; for debugging and then left there. &lt;strong&gt;&lt;code&gt;RunAtLoad&lt;/code&gt; in a production plist is &lt;code&gt;false&lt;/code&gt; as a rule.&lt;/strong&gt;&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Best practices
&lt;/h2&gt;

&lt;p&gt;From the experience of maintaining a 160-job fleet, here are only the rules that, had I followed them, would have prevented an actual incident.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Whenever you touch a plist, always follow the order &lt;code&gt;plutil -lint → bootout → bootstrap → launchctl print&lt;/code&gt;.&lt;/strong&gt;&lt;br&gt;
Break this order and you can no longer isolate why something doesn't work. &lt;code&gt;plutil -lint&lt;/code&gt; is the one verification you can run "before stopping the job." Skip it and a broken plist silently surfaces.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. &lt;code&gt;bootout&lt;/code&gt; errors can be ignored; &lt;code&gt;bootstrap&lt;/code&gt; errors must not be.&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;bootout&lt;/code&gt; is idempotent. Running it on an unloaded job just returns &lt;code&gt;No such process&lt;/code&gt;, which isn't an anomaly. On the other hand, if &lt;code&gt;bootstrap&lt;/code&gt; fails with &lt;code&gt;5: Input/output error&lt;/code&gt;, the job is left unloaded and gone. The only place &lt;code&gt;2&amp;gt;/dev/null&lt;/code&gt; belongs in a reload script is on the &lt;code&gt;bootout&lt;/code&gt; side.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. For verification, use &lt;code&gt;launchctl print&lt;/code&gt;, not &lt;code&gt;launchctl list&lt;/code&gt;.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 退役確認（消えていればOK）&lt;/span&gt;
launchctl list | &lt;span class="nb"&gt;grep &lt;/span&gt;com.shun.zenn-daily

&lt;span class="c"&gt;# 再ロード後の定義確認（新しい定義で動いているか）&lt;/span&gt;
launchctl print gui/&lt;span class="nv"&gt;$UID&lt;/span&gt;/com.shun.zenn-daily | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"program ="&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Showing up in &lt;code&gt;launchctl list&lt;/code&gt; is evidence of "registered," not evidence of "loaded with the latest plist definition." Use &lt;code&gt;launchctl print&lt;/code&gt; to verify the definition.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Build the three keys &lt;code&gt;ProcessType=Background / Nice=10 / LowPriorityIO=true&lt;/code&gt; into your template.&lt;/strong&gt;&lt;br&gt;
Adding them by hand every time you write a new plist leads to omissions. Put these three keys in your standard template file and copy from it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Make &lt;code&gt;StartCalendarInterval&lt;/code&gt; a multi-slot dict array, not a single dict.&lt;/strong&gt;&lt;br&gt;
It's insurance against firings lost to sleep. With 3 slots, if one is lost to sleep, one of the remaining two gets through. When switching to multi-slot, always add an idempotency guard (skip if already succeeded today) at the same time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Design schedules from the total occupancy of the exclusive resource.&lt;/strong&gt;&lt;br&gt;
If multiple jobs use the same lock, first compute the maximum frequency from the fleet-wide concurrency and per-run duration. Fitting 11 lanes into 120 minutes means the maximum adjacent interval is &lt;code&gt;120/11 ≈ 10.9 minutes&lt;/code&gt;. "All gaps 11+ minutes" and "strictly every 2 hours" are mathematically incompatible. If you compromise in the design, leave a comment stating explicitly which one you broke.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;7. Write every path inside a plist as an absolute path.&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;~&lt;/code&gt; isn't expanded because launchd doesn't run as a shell. Tilde notation is fine in articles and explanations, but the real file gets absolute paths only. The same applies when setting PATH in &lt;code&gt;EnvironmentVariables&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;8. Write environment variable assignments in the &lt;code&gt;:-&lt;/code&gt; form.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# NG: ラッパが渡した値を上書きする&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;CLAUDE_BIN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;~/.local/bin/claude

&lt;span class="c"&gt;# OK: 既存値がある場合は尊重する&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;CLAUDE_BIN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CLAUDE_BIN&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="p"&gt;~/.local/bin/claude&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Even with the wrapper wired in, if a shell script along the path assigns unconditionally, the guard never arrives. After wiring, sweep everything with &lt;code&gt;grep -r 'CLAUDE_BIN' ~/dev ~/.claude/scripts&lt;/code&gt; and eliminate any assignment not in the &lt;code&gt;:-&lt;/code&gt; form.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;9. Put a count assertion on the test's &lt;code&gt;LABELS&lt;/code&gt; constant.&lt;/strong&gt;&lt;br&gt;
Manage "the list of jobs that should have this wrapper" in a LABELS constant in the test, and assert the count too, like &lt;code&gt;assert len(labels) == 44&lt;/code&gt;. When you add a job, the count test fails and "the new job doesn't have the wrapper" is detected mechanically. A test that only checks label existence passes even on an old commented-out definition.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;10. Back up to a dated directory and confirm full byte-for-byte match with &lt;code&gt;cmp&lt;/code&gt; before moving.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;BACKUP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;~/content/launchagents-backup-&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%Y%m%d&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BACKUP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;cp&lt;/span&gt; ~/Library/LaunchAgents/com.shun.zenn-daily.plist &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BACKUP&lt;/span&gt;&lt;span class="s2"&gt;/"&lt;/span&gt;
cmp ~/Library/LaunchAgents/com.shun.zenn-daily.plist &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BACKUP&lt;/span&gt;&lt;span class="s2"&gt;/com.shun.zenn-daily.plist"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"backup OK"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"backup FAILED"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;cp&lt;/code&gt; occasionally produces a file with a different byte count without raising an error (iCloud write delays, etc.). I make it a habit to confirm with &lt;code&gt;cmp&lt;/code&gt; before touching the original.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;11. Run a monthly inventory script on a schedule to flush out "ghost jobs."&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;launchctl list | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'com\.lily\|com\.shun'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $3}'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/loaded.txt

&lt;span class="k"&gt;for &lt;/span&gt;f &lt;span class="k"&gt;in&lt;/span&gt; ~/Library/LaunchAgents/com.&lt;span class="o"&gt;{&lt;/span&gt;lily,shun&lt;span class="o"&gt;}&lt;/span&gt;.&lt;span class="k"&gt;*&lt;/span&gt;.plist&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;defaults &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; Label 2&amp;gt;/dev/null
&lt;span class="k"&gt;done&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/files.txt

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"=== ファイルなし・ロード済み（幽霊）==="&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;comm&lt;/span&gt; &lt;span class="nt"&gt;-23&lt;/span&gt; /tmp/loaded.txt /tmp/files.txt
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"=== plistあり・アンロード済み ==="&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;comm&lt;/span&gt; &lt;span class="nt"&gt;-13&lt;/span&gt; /tmp/loaded.txt /tmp/files.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lines under "no file, loaded" are ghost jobs. Any job you thought you'd stopped with a &lt;code&gt;.retired&lt;/code&gt; rename that's survived across sessions will show up here without fail. Run it once a month and any missed step gets caught in next month's inventory.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;12. Before lowering a zero-result job's frequency, verify its reason for existing.&lt;/strong&gt;&lt;br&gt;
That a job is running (exit 0) and that it's producing results (posts, follows, replies actually happening) are different things. There was a real case where, past the point of judging "it's running" from &lt;code&gt;exit 0&lt;/code&gt; in the logs alone, everything had been zero for 3 weeks. Frequency adjustment comes later; first, check in the actual logs whether "this job produced even one real result today."&lt;/p&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;com.shun.zenn-daily.plist.retired&lt;/code&gt; still exists on the filesystem today. The file name changed, but the &lt;code&gt;Label&lt;/code&gt; inside is still &lt;code&gt;com.shun.zenn-daily&lt;/code&gt;. Unless &lt;code&gt;launchctl bootout gui/$UID/com.shun.zenn-daily&lt;/code&gt; was run, this job fires again today at 7:30, 10:00, 14:00, and 19:00.&lt;/p&gt;

&lt;p&gt;Declaring retirement and executing it are separate operations.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The rename is the declaration. It doesn't change launchd's table.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;bootout&lt;/code&gt; is the execution. Only once this goes through does the entry leave the table.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;launchctl print&lt;/code&gt; is the verification. Only after confirming the live definition with &lt;code&gt;print&lt;/code&gt;, not &lt;code&gt;list&lt;/code&gt;, is retirement "complete."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;As automation scales up, you reboot the Mac less often. The longer a session goes without a reboot, the longer jobs you "stopped" with a rename alone survive. On a fleet past 160 jobs, "I stopped it but it's running," "I changed it but it didn't take," and "it's running but producing zero" all happen at the same time.&lt;/p&gt;

&lt;p&gt;One monthly inventory script and the &lt;code&gt;bootout → list → print&lt;/code&gt; verification habit prevent almost every failure in this class. The commands are three lines. It's not about the procedure — it's entirely about whether you know.&lt;/p&gt;

&lt;p&gt;How many jobs are sitting in your &lt;code&gt;launchctl list&lt;/code&gt; right now that you're sure you retired?&lt;/p&gt;




&lt;p&gt;The full picture of the system, the breakdown of the ¥1.2M/month, and the 30-day procedure are in a paid note (Japanese).&lt;br&gt;
📕 &lt;a href="https://note.com/bokuwalily/n/n849b3a07784a" rel="noopener noreferrer"&gt;Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by **Lily&lt;/em&gt;* — I ship iOS apps and automate my content stack with Claude Code.&lt;br&gt;
Follow along: &lt;a href="https://bokuwalily.com" rel="noopener noreferrer"&gt;Portfolio&lt;/a&gt; · &lt;a href="https://x.com/bokuwalily" rel="noopener noreferrer"&gt;X&lt;/a&gt; · &lt;a href="https://github.com/bokuwalily" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;*&lt;/p&gt;

</description>
      <category>launchd</category>
      <category>macos</category>
      <category>automation</category>
      <category>devops</category>
    </item>
    <item>
      <title>3 Duplicate Instagram Posts in 8 Hours: Anatomy of a False-Negative Success Check</title>
      <dc:creator>Lily</dc:creator>
      <pubDate>Thu, 17 Sep 2026 05:00:06 +0000</pubDate>
      <link>https://dev.to/bokuwalily/3-duplicate-instagram-posts-in-8-hours-anatomy-of-a-false-negative-success-check-3p1p</link>
      <guid>https://dev.to/bokuwalily/3-duplicate-instagram-posts-in-8-hours-anatomy-of-a-false-negative-success-check-3p1p</guid>
      <description>&lt;p&gt;I went from ¥100k/month as a student, to ¥600k juggling side gigs, to zero after a layoff, and then spent six months building an autonomous Claude Code environment that brought me back to ¥1.2M/month. What I learned along the way wasn't a string of automation wins. It was a pile of lessons about &lt;strong&gt;what happens when automation breaks&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;On August 28, 2026, the same Instagram carousel showed up three times on my profile. Shortcodes &lt;code&gt;DckjvpPoFRt&lt;/code&gt;, &lt;code&gt;DckN-yYkIBsJ&lt;/code&gt;, and &lt;code&gt;DclaPYooPjB&lt;/code&gt; — posted at 13:58, 17:55, and 21:54 JST. All three times, the job logged "post not found on profile." All three times, a direct check against the Instagram API showed the post had gone through.&lt;/p&gt;

&lt;p&gt;The operation succeeded, and the check called it a failure — a &lt;strong&gt;false negative&lt;/strong&gt; that repeated a side effect three times over.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this setup works at all
&lt;/h2&gt;

&lt;p&gt;"Building an environment" means driving the number of times I have to touch anything toward zero.&lt;/p&gt;

&lt;p&gt;For Instagram, launchd fires a script on schedule, and that script runs caption generation, posting, landing verification, and ledger write-back end to end with no human in the loop. Around 171 jobs run side by side in total, and the Instagram carousel post is one lane. I never have to go check "did today's post go out?" — the &lt;code&gt;ok:true&lt;/code&gt; entry in the ledger and the Discord notification tell me instead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The reason this is an "environment" rather than "work" is that the environment also decides when to retry.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If landing verification (&lt;code&gt;_verify_landed()&lt;/code&gt;) returns &lt;code&gt;ok:false&lt;/code&gt;, the same job runs again at the next scheduled slot. Instead of a human going "oh, that failed, let me try again," the scheduler re-runs it automatically. Zero time, zero effort — &lt;strong&gt;as long as the verdict is correct.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When the verdict is wrong, the mechanism runs in reverse. If the post succeeded but &lt;code&gt;ok:false&lt;/code&gt; keeps coming back, the scheduler interprets that as "still not posted" and sends the same content again. The side effect (the post) is already done, but the verification layer insists it isn't. Let retries run in that state and identical posts pile up without limit.&lt;/p&gt;

&lt;h3&gt;
  
  
  "Producing more" is worse than "stopping"
&lt;/h3&gt;

&lt;p&gt;A false positive — "it actually failed, but we called it a success" — stops the shipping line. That's a visible failure. Someone notices and investigates.&lt;/p&gt;

&lt;p&gt;A false negative — "it actually succeeded, but we called it a failure" — increases shipments. That's an invisible failure. The ledger fills with &lt;code&gt;ok:false&lt;/code&gt;, and on the surface it looks like "posts are running late." Meanwhile the same post is being mass-produced, and the script calmly schedules the next retry.&lt;/p&gt;

&lt;p&gt;For an operation with side effects, the damage from a false negative is &lt;strong&gt;not "a failed check" but "a duplicated side effect."&lt;/strong&gt; Every wrong verdict leaves another trace in the real world. In Instagram's case, that trace was three identical carousels lined up on my profile.&lt;/p&gt;

&lt;p&gt;My learning notes record it this way: "[[false-positive-stops-the-line]] was the 'a false alarm halts shipping' pattern. This one is &lt;strong&gt;a false alarm that increases shipping&lt;/strong&gt;. Worse than stopping."&lt;/p&gt;

&lt;h3&gt;
  
  
  The condition under which an environment works
&lt;/h3&gt;

&lt;p&gt;171 jobs run efficiently because each one can accurately judge whether its own work is done. If the verdict is accurate, a successful job runs once, and only failed jobs get re-executed.&lt;/p&gt;

&lt;p&gt;When the verdict breaks, that premise collapses. Successful jobs get treated as failures and re-run too. If the side effect is idempotent (same result no matter how many times you do it), no problem — but a social media post is not idempotent. Send once, one post goes public. Send three times, three go public.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before asking "how many retries should this have?", you have to ask "is this operation idempotent?"&lt;/strong&gt; Attaching three retries to a non-idempotent operation is a declaration that you'll tolerate the side effect up to three times. My retry-design reference (&lt;code&gt;retry-and-giveup-design.md&lt;/code&gt;) says "throwing the same failure three times doesn't change the result," and it's the same point — when the problem is in the input to the verdict itself, adding attempts changes nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The full sequence
&lt;/h2&gt;

&lt;p&gt;Here's the timeline of what happened on the day of the incident.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[launchd: 13:58 JST]
        │
        ▼
  ig_autopost.py
        │
        ├─ (1) キャプション生成・投稿実行
        │       IG カルーセル送信 ─────────────► 成功 ✓ (DckjvpPoFRt)
        │
        └─ (2) 着地確認: _verify_landed()
                │
                ├─ プロフィール DOM 取得
                │   キャプション先頭12文字: "👾 個人開発の量産..."
                │   IG レンダリング:         &amp;lt;img alt="👾"&amp;gt; ← inner_text に出ない
                │   照合文字列(DOM側):        " 個人開発の量産 v"  ← ズレる
                │
                └─ 照合NG → ok: false → 台帳: 「投稿されていない」
                        │
                        ▼
[launchd: 17:55 JST] ← スケジューラ「まだ未投稿」と判断・再実行
        │
        ├─ 同じキャプションで投稿 ──────────────► 重複1本目 (DckN-yYkIBsJ)
        └─ _verify_landed() → ok: false ← また偽陰性
                        │
                        ▼
[launchd: 21:54 JST] ← さらに再実行
        ├─ 投稿 ───────────────────────────────► 重複2本目 (DclaPYooPjB)
        └─ _verify_landed() → ok: true ← ようやく成功判定
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three posts in eight hours, all the same carousel. The script exited all three times believing it had "worked correctly." Only checking the IG API revealed the duplicates.&lt;/p&gt;

&lt;h3&gt;
  
  
  Root cause: the matching input was broken
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;_verify_landed()&lt;/code&gt; fetched the profile page DOM and compared the &lt;strong&gt;first 12 characters of the body text&lt;/strong&gt; with the first 12 characters of the caption. The design assumed "the DOM's &lt;code&gt;inner_text&lt;/code&gt; contains the entire caption."&lt;/p&gt;

&lt;p&gt;But Instagram renders emoji as &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; tags. When the caption starts with &lt;code&gt;👾&lt;/code&gt;, the DOM contains &lt;code&gt;&amp;lt;img alt="👾"&amp;gt;&lt;/code&gt;, and that character never appears in &lt;code&gt;inner_text&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;キャプション文字列: "👾 個人開発の量産 vol..."
照合しようとした文字列(先頭12文字): "👾 個人開発の量産 vo"
DOM inner_text(先頭12文字):         " 個人開発の量産 vol."  ← 👾 が img に化けて消える

→ 永久に不一致
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This kind of false negative doesn't break on "did the click work (did the post go out)?" — it breaks on &lt;strong&gt;"what did you compare?"&lt;/strong&gt; The matching mechanism runs fine. The material it's matching is corrupted.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix, in three parts
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;1. Caption normalization (strip emoji and full-width characters)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Transform the match key into a form that doesn't depend on how the DOM renders. By passing &lt;strong&gt;both sides&lt;/strong&gt; — the caption string and the body text pulled from the DOM — through the same function, neither side can drop something the other keeps.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;normalize_caption_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;unicodedata&lt;/span&gt;
    &lt;span class="c1"&gt;# 全角・半角空白を除去
&lt;/span&gt;    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isspace&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="c1"&gt;# BMP外（絵文字 ord &amp;gt; 0xFFFF）と Unicode カテゴリ So/Sk を除去
&lt;/span&gt;    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;ord&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mh"&gt;0xFFFF&lt;/span&gt;
        &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;unicodedata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;category&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;So&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Sk&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# 先頭24文字（残りが24文字未満ならキャプション全体）
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The match width grew from 12 characters to &lt;strong&gt;24&lt;/strong&gt; as a margin for the emoji that normalization strips out. The original 12-character rule was designed for unnormalized strings; for the normalized remainder to carry the same amount of information, it has to be longer.&lt;/p&gt;

&lt;p&gt;"Apply the same normalization to both the caption and the page body" — that's the crux. Normalize only one side and the transformed lengths won't line up, producing a different mismatch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. API-first lookup, with the DOM fallback cut off&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The DOM's &lt;code&gt;inner_text&lt;/code&gt; depends on how IG renders things, and IG can change that at any moment. An API-first lookup removes that uncertainty.&lt;/p&gt;

&lt;p&gt;Using the &lt;code&gt;ds_user_id&lt;/code&gt; cookie from the logged-in page, the script queries the internal API directly. If the API returns JSON (i.e., it responded normally) but the post isn't there, the result is &lt;strong&gt;locked in as a failure without falling back to the DOM&lt;/strong&gt;. Only when all 8 queries return non-JSON (the API is broken, etc.) does it fall back to a single DOM check.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;API 問い合わせ (最大8回)
        │
        ├─ JSON 返答あり + 投稿あり  → ok: true
        ├─ JSON 返答あり + 投稿なし  → ok: false（DOM に逃がさない）
        └─ 8回とも JSON 以外         → DOM を1回だけ確認 → ok / false
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is designed so that "couldn't verify" is never mistranslated as "not posted." If you escape to the DOM whenever the API is down, you're right back to &lt;code&gt;inner_text&lt;/code&gt; matching, and the false-negative path stays open.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Fail-closed design&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When API verification is impossible, treat the default as "already posted." The reasoning is asymmetry.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Misjudged as not posted → re-run → duplicate": the actual damage this time (3 duplicates, cleanup work afterward)&lt;/li&gt;
&lt;li&gt;"Misjudged as posted → skip → one slot's delay": naturally recovered at the next scheduled run&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A duplicate can be deleted later, but it was public for a while and someone has to delete it. A delay is absorbed by the next job. For asymmetric damage, fail-closed — defaulting to the lighter outcome — is the right call.&lt;/p&gt;

&lt;p&gt;Alongside this, I introduced an &lt;code&gt;already-live&lt;/code&gt; verdict so that posts that are already public don't get recorded as failures in the ledger.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# 既存投稿として検出した場合
&lt;/span&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;note&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;already-live&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If it lands in the ledger as &lt;code&gt;ok:false&lt;/code&gt;, the next retry check treats it as "not posted." Recording &lt;code&gt;ok:true&lt;/code&gt; is what makes the duplicate guard actually work.&lt;/p&gt;

&lt;p&gt;All three fixes landed without touching IG at all — static implementation changes plus pure-function tests (&lt;code&gt;pytest&lt;/code&gt; 12 passed). The pre-post duplicate check (&lt;code&gt;has_existing_caption()&lt;/code&gt;) was also wired in as a shared function for both the carousel and reel lanes, and it likewise stops fail-closed when the API can't be verified.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation details
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;normalize_caption_key&lt;/code&gt; — why it ended up this way
&lt;/h3&gt;

&lt;p&gt;Let's break the normalization function from above down one more level.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;normalize_caption_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;unicodedata&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isspace&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;ord&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mh"&gt;0xFFFF&lt;/span&gt;
        &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;unicodedata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;category&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;So&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Sk&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The condition &lt;code&gt;ord(c) &amp;lt;= 0xFFFF&lt;/code&gt;&lt;/strong&gt; keeps only characters within the BMP (Basic Multilingual Plane). Most emoji sit at &lt;code&gt;U+1F000&lt;/code&gt; and above, meaning &lt;code&gt;ord&lt;/code&gt; exceeds 65535. &lt;code&gt;👾&lt;/code&gt; has an &lt;code&gt;ord&lt;/code&gt; of &lt;code&gt;128126&lt;/code&gt;, so it's excluded immediately. The Unicode categories &lt;code&gt;So&lt;/code&gt; (Symbol, Other) and &lt;code&gt;Sk&lt;/code&gt; (Symbol, Modifier) are also removed because decorative symbols exist inside the BMP too. &lt;code&gt;★&lt;/code&gt; (U+2605, category &lt;code&gt;So&lt;/code&gt;), for instance, fits in the BMP but can't be relied on to appear consistently in &lt;code&gt;inner_text&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The 24-character match width&lt;/strong&gt; was worked backwards from measurement. The original 12 characters were designed for unnormalized strings. After normalization, the string is shorter by however many emoji were dropped. With a caption opening like &lt;code&gt;👾 個人開発の量産 vol.XX ——&lt;/code&gt;, the first 12 characters after &lt;code&gt;👾&lt;/code&gt; falls away become &lt;code&gt;「 個人開発の量産 v」&lt;/code&gt; — a fairly generic string. Extending to 24 reads through &lt;code&gt;「 個人開発の量産 vol.18 ——」&lt;/code&gt;, which is much less likely to collide with another post.&lt;/p&gt;

&lt;p&gt;The key point is &lt;strong&gt;what the function is applied to&lt;/strong&gt;. The same &lt;code&gt;normalize_caption_key&lt;/code&gt; goes over &lt;strong&gt;both&lt;/strong&gt; the caption string and the page body pulled from the DOM. Normalize only one side and the post-transform character counts diverge, producing a fresh mismatch. What &lt;code&gt;pytest&lt;/code&gt; verified was exactly this scenario: "do both sides match after going through the function?"&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# テストの骨格
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_normalize_both_sides&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;caption&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;👾 個人開発の量産 vol.18 —— &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;dom_text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt; 個人開発の量産 vol.18 —— &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;  &lt;span class="c1"&gt;# IG が img に変換した後
&lt;/span&gt;    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="nf"&gt;normalize_caption_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;caption&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nf"&gt;normalize_caption_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dom_text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's a pure function, so it can be verified without connecting to IG at all. That's what "12 passed in &lt;code&gt;pytest&lt;/code&gt; with no IG connection" actually means.&lt;/p&gt;

&lt;h3&gt;
  
  
  API-first lookup — separating "couldn't verify" from "not posted"
&lt;/h3&gt;

&lt;p&gt;Turning the flow above into code, the decision branches look like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_verify_via_api&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;shortcode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;caption&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_fetch_profile_api&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;DS_USER_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;...)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;# JSON以外が返ってきた
&lt;/span&gt;            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="c1"&gt;# JSONが返った = API正常
&lt;/span&gt;        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;post&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;edges&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]):&lt;/span&gt;
            &lt;span class="n"&gt;node_text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;post&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;node&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;edge_media_to_caption&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt;
            &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;normalize_caption_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node_text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;edges&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[{}])[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;node&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nf"&gt;normalize_caption_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;caption&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;source&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;api&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="c1"&gt;# JSONが返ったが該当なし → DOMへ逃がさない
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;source&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;api&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reason&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;not_found&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;# 8回ともJSONでなかった → DOMを1回だけ
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_verify_via_dom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;caption&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The core is the asymmetry between the &lt;code&gt;resp is None&lt;/code&gt; branch and the &lt;code&gt;return {"ok": False}&lt;/code&gt; branch. If "the API is up and returned JSON" but "the post isn't found," that is definitive information: it wasn't posted. There's no reason to escape to the DOM. Only when "none of the 8 attempts returned JSON" does it fall back to a DOM check.&lt;/p&gt;

&lt;p&gt;Why 8? Requests to the internal API sometimes hit Instagram's rate limiting and return normally after one or two failures. But if all 8 come back non-JSON, either the API is broken or the session has expired, and asking further won't change the outcome.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The reason for using the &lt;code&gt;ds_user_id&lt;/code&gt; cookie&lt;/strong&gt; is that the logged-in internal API returns post lists as JSON even for profiles you don't follow. Pagination uses the &lt;code&gt;after&lt;/code&gt; cursor when needed, but landing verification only checks the most recent posts, so one page is enough.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fail-closed and the &lt;code&gt;already-live&lt;/code&gt; ledger design
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# API確認が8回とも不能 → DOMへ行ったが結果も不明 → fail-closed
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_verify_landed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;shortcode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;caption&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_verify_via_api&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;shortcode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;caption&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;source&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;api&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;
    &lt;span class="c1"&gt;# DOMフォールバックの結果
&lt;/span&gt;    &lt;span class="n"&gt;dom_result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;dom_result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;# 確認不能
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;note&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unverified-assumed-live&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;dom_result&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The record format &lt;code&gt;ok: True, note: "already-live"&lt;/code&gt; exists because the ledger reader uses &lt;code&gt;ok:true&lt;/code&gt; for its skip decision. Recorded as &lt;code&gt;ok:false&lt;/code&gt;, the entry gets reprocessed as "not posted" at the next retry. The &lt;code&gt;note&lt;/code&gt; field is there so a human reading the ledger can see "why was this treated as success?" — the script logic doesn't use it. Without that distinction, three months from now you'll open the ledger and have no answer to "why is this one true?"&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A second check based on post-count delta&lt;/strong&gt; is included as well. It fetches the profile's post count before and after posting; if the difference is +1, it declares success independently of caption matching. This is a safety net for cases where caption normalization fails to match for whatever reason. Both checks use the same internal API, but they look at different "material" (caption vs. count), so if one breaks, the verdict doesn't die completely.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where I got stuck
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The day after the fix, 16 empty posts showed up
&lt;/h3&gt;

&lt;p&gt;On August 30, two days after the August 28 fix (normalization + API-first + fail-closed), &lt;strong&gt;16 carousels with no body text&lt;/strong&gt; went public. Two patterns, eight rounds each.&lt;/p&gt;

&lt;p&gt;The symptom was "duplicates again," but this time the cause wasn't matching — it was &lt;strong&gt;the posting side&lt;/strong&gt;. &lt;code&gt;keyboard.insert_text&lt;/code&gt; in &lt;code&gt;ig_autopost.py&lt;/code&gt; never made it into the caption field, and the screenshots showed &lt;strong&gt;"Share" being pressed at 0/2200&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This incident was nastier than the previous one, as my learning notes put it:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;🔴 &lt;strong&gt;The pre-post duplicate guard (&lt;code&gt;has_existing_caption&lt;/code&gt;) and the post-post landing check (&lt;code&gt;_verify_landed&lt;/code&gt;) share the same internal API and the same 24-character key (&lt;code&gt;normalize_caption_key&lt;/code&gt;).&lt;/strong&gt; With an empty body, &lt;strong&gt;neither matches&lt;/strong&gt; → the duplicate guard says "not out yet," the landing check says "not out" → treated as failure, &lt;strong&gt;same ID re-posted&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Extracting the pre-post duplicate check into a shared function on August 28 was the right call. But as a result of sharing it, &lt;strong&gt;the failure mode was shared too&lt;/strong&gt;. A single function is correct from a reuse standpoint, and it's fine as long as the caption goes in properly. But against the path where the caption comes out empty, every guard that shares it is neutralized simultaneously.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix came in two stages.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stage one: confirm the caption went in before posting.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;insert_caption_with_verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;caption&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# 手段1: keyboard.insert_text
&lt;/span&gt;    &lt;span class="c1"&gt;# 手段2: clipboard経由でpaste
&lt;/span&gt;    &lt;span class="c1"&gt;# 手段3: JSでvalue直接セット
&lt;/span&gt;    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;method&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;_keyboard_insert&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_clipboard_paste&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_js_set_value&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="nf"&gt;method&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;caption&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;actual&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;[data-lexical-editor]&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;inner_text&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;actual&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;caption&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;# 5文字マージン
&lt;/span&gt;            &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;キャプション投入失敗: 期待&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;caption&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;文字/実測&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;actual&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;文字&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"Did it go in?" is decided by &lt;strong&gt;the actual character count in the field&lt;/strong&gt;, not by the return value of &lt;code&gt;keyboard.insert_text&lt;/code&gt;. The API can report "inserted" while the DOM rendering hasn't caught up. In a live run, it printed &lt;code&gt;キャプション投入 1257文字 / 期待1257文字&lt;/code&gt; and then landing verification succeeded in 13 seconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stage two: block the path where the match key becomes empty, at the entrance.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;normalize_caption_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# ...（前述の正規化）
&lt;/span&gt;    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;照合キーが空: 投稿前に中止してください&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An empty key isn't "no match" — it's a third state: "the comparison never happened." Record the result of a comparison that never happened as &lt;code&gt;ok:false&lt;/code&gt; in the ledger and a retry fires, multiplying the side effect. Stopping at the entrance with an explicit error breaks the chain of "can't compare → re-post."&lt;/p&gt;




&lt;h3&gt;
  
  
  TikTok threw errors for 15 straight days, but every post had gone through
&lt;/h3&gt;

&lt;p&gt;On September 8, I discovered that &lt;code&gt;post_reel_tiktok.py&lt;/code&gt; had been exiting with &lt;code&gt;RuntimeError&lt;/code&gt; every time since September 24. For 15 days, the ledger accumulated a daily &lt;code&gt;ok:false&lt;/code&gt;. But when I checked the TikTok dashboard, every single post had succeeded.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The cause: the success check depended on the vendor's fixed wording.&lt;/strong&gt; After clicking Post, the script polled the page body for 120 seconds waiting for one of five fixed strings to appear.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;SUCCESS_TEXTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;動画が投稿されました&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;投稿を作成しました&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;コンテンツが公開されました&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;# ...他2種
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;TikTok was navigating to the content management page after posting (URL containing &lt;code&gt;/tiktokstudio/content&lt;/code&gt;), but the sidebar label on that screen had changed from 「管理する」 to &lt;strong&gt;「管理」&lt;/strong&gt;. Nothing in &lt;code&gt;SUCCESS_TEXTS&lt;/code&gt; matched, so it threw &lt;code&gt;RuntimeError&lt;/code&gt; every time.&lt;/p&gt;

&lt;p&gt;There are two reasons it went unnoticed for 15 days. First, the ledger's &lt;code&gt;skip&lt;/code&gt; logic only looked at &lt;code&gt;ok:true&lt;/code&gt; entries. Video IDs recorded as &lt;code&gt;ok:false&lt;/code&gt; became candidates for reprocessing as "not yet posted" on the next run. They &lt;em&gt;had&lt;/em&gt; been posted, so had a re-run actually happened, the same video would have gone public twice. The only reason no duplicates appeared is that the &lt;code&gt;ok:false&lt;/code&gt; IDs didn't happen to reach the front of the queue — new videos were prioritized.&lt;/p&gt;

&lt;p&gt;Second, &lt;strong&gt;the failure exception carried neither the page URL nor the start of the body&lt;/strong&gt;. A single line — &lt;code&gt;RuntimeError: タイムアウト: 成功文言が見つかりません&lt;/code&gt; — gives you no way to diagnose "TikTok changed its wording." When an error leaves no evidence at the scene, recovery time balloons. The 15 days were caused by that missing record.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix was to layer the checks.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_is_posted_successfully&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# 判定1: 従来の成功文言（TikTokが変えるかも）
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;inner_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;SUCCESS_TEXTS&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="c1"&gt;# 判定2: URLがuploadを離れてcontentに着いた
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/tiktokstudio/content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="c1"&gt;# 判定3: 自分が投げたキャプションの先頭12文字が本文にある
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;normalize_caption_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;caption&lt;/span&gt;&lt;span class="p"&gt;)[:&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;inner_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check 3 — "verify using the caption I submitted" — is the most robust. Even if the vendor changes the UI, if I'm the one who posted, my own text will be on the screen. Checks 1 and 2, which depend on the vendor's vocabulary, become fallbacks.&lt;/p&gt;

&lt;p&gt;My learning notes record it like this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A check that depends on vendor wording can't be fixed unless you preserve the evidence at the moment it fails&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Designing your error records matters as much as designing your retries. Always attaching &lt;code&gt;page.url&lt;/code&gt; and the first 200 characters of &lt;code&gt;body&lt;/code&gt; to the exception dates from this incident.&lt;/p&gt;




&lt;h3&gt;
  
  
  X (formerly Twitter) quote posts showed "0" for five days straight
&lt;/h3&gt;

&lt;p&gt;On September 10, I noticed that every quote post since September 5 had been counted as &lt;code&gt;quoted=0&lt;/code&gt;. They had actually been posted, but the landing verification script returned &lt;code&gt;unverified&lt;/code&gt; for all of them and never incremented the count.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The cause was Playwright's strict mode combined with a DOM structure change.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 変更前&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tweetText&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[data-testid="tweetText"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;innerText&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A quote post's article element contains &lt;strong&gt;two&lt;/strong&gt; &lt;code&gt;tweetText&lt;/code&gt; nodes: my own text and the embedded original post. In strict mode, Playwright's &lt;code&gt;innerText()&lt;/code&gt; throws when multiple elements match. That error was caught and turned into an empty string, the comparison fell to &lt;code&gt;unverified&lt;/code&gt;, and the count stayed at &lt;code&gt;quoted=0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The fix is &lt;code&gt;.first()&lt;/code&gt; to read only the first element.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tweetText&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[data-testid="tweetText"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;innerText&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A one-line change, but it &lt;strong&gt;stopped five days of false alerts&lt;/strong&gt;. &lt;code&gt;health.mjs&lt;/code&gt; had been pushing "0 today" notifications to the desktop, so for five days the state was "all quote posts are failing, cause unknown." In reality nothing was broken and every post had succeeded.&lt;/p&gt;

&lt;p&gt;This incident has exactly the same structure as TikTok's 15 straight days of exit 1. "When the vendor's DOM goes from one element to multiple, a strict comparison falls to failure and produces a false negative" — same structure, different platform, two weeks later. If I'd been checking that pattern across lanes after learning it once, it would have been closed in 0 days, not 5.&lt;/p&gt;

&lt;p&gt;This is the one that stung most. I &lt;em&gt;knew&lt;/em&gt; this failure mode, and still couldn't prevent its recurrence on another platform. After the fix, I preemptively added &lt;code&gt;.first()&lt;/code&gt; to the &lt;code&gt;reply.mjs&lt;/code&gt; in every lane using the same kind of comparison — but that was after the fact.&lt;/p&gt;




&lt;h3&gt;
  
  
  What the three failures have in common
&lt;/h3&gt;

&lt;p&gt;The 16 empty-caption posts on August 30, TikTok's 15 consecutive exit 1s, X quote posts at zero for 5 days — what they share is this structure: &lt;strong&gt;the comparator can't tell you that the comparator itself is broken&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The script keeps returning &lt;code&gt;ok:false&lt;/code&gt;. The ledger accumulates failures. The scheduler books re-runs. Nowhere in that chain is there a signal saying "the input to the verdict is broken." The script is working correctly — with the wrong material.&lt;/p&gt;

&lt;p&gt;My retry-design reference (&lt;code&gt;retry-and-giveup-design.md&lt;/code&gt;) has this line:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Throwing the same failure three times doesn't change the result. Suspect what you're passing in, not the count.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;All three cases are exactly this. Cut retries to two, leave them at three — as long as the comparison material is broken, nothing changes. When the verdict falls the same way three times in a row — consecutive false negatives — the first thing to suspect is "what am I using as comparison material?"&lt;/p&gt;

&lt;p&gt;&lt;code&gt;inner_text&lt;/code&gt;'s rendering dependency, a vendor changing fixed wording, strict mode's element-count sensitivity — all three are the pattern "the comparison logic I wrote is correct, but the outside environment invalidated the comparison." Adjusting retry counts is powerless against this pattern. You either change the material or change how you compare. There's no third option.&lt;/p&gt;

&lt;p&gt;In my current environment, when landing verification fails, the next step is not an immediate retry but &lt;strong&gt;asking "why did it fail?" through a separate path&lt;/strong&gt;. On failure, it hits the API directly to check "does the post exist?" If it does, it's recorded as &lt;code&gt;already-live&lt;/code&gt;. Only if it doesn't is "genuinely not posted" confirmed. That one extra step — "verify, then decide" — is the last wall against duplicated side effects.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfalls
&lt;/h2&gt;

&lt;p&gt;A full list of the landmines I actually stepped on. Every one follows the pattern "the script exits normally, and only the real world is broken."&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Symbols other than emoji also vanish from inner_text.&lt;/strong&gt; &lt;code&gt;★&lt;/code&gt; (U+2605, Unicode category &lt;code&gt;So&lt;/code&gt;) is a BMP character, but it can't be relied on to appear consistently in &lt;code&gt;inner_text&lt;/code&gt; under IG's rendering. I thought &lt;code&gt;ord(c) &amp;lt;= 0xFFFF&lt;/code&gt; was enough; this is why categories &lt;code&gt;So&lt;/code&gt; (Symbol, Other) and &lt;code&gt;Sk&lt;/code&gt; (Symbol, Modifier) are additionally stripped. Base normalization on "is it in the BMP?" alone and the next incident is on its way.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Normalizing the match key on only one side.&lt;/strong&gt; Normalize just the caption string and compare against the raw DOM text, and the character counts diverge by however many emoji were dropped, creating a new mismatch. Always pass &lt;code&gt;normalize_caption_key&lt;/code&gt; over &lt;strong&gt;both&lt;/strong&gt; sides — the easiest thing to forget in the whole implementation.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Not closing the path where the match key becomes empty.&lt;/strong&gt; This was the root cause of the 16 body-less carousels on August 30. &lt;code&gt;keyboard.insert_text&lt;/code&gt; in &lt;code&gt;ig_autopost.py&lt;/code&gt; never reached the caption field, and "Share" was pressed at 0/2200. The pre-post duplicate guard (&lt;code&gt;has_existing_caption&lt;/code&gt;) and the post-post landing check (&lt;code&gt;_verify_landed&lt;/code&gt;) &lt;strong&gt;share the same 24-character key&lt;/strong&gt;, so the moment the key goes empty, both are neutralized at once. The duplicate guard says "not out yet," the landing check says "not out," and the same content keeps getting retried.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;"Prevention" and "detection" sharing the same comparator.&lt;/strong&gt; Correct from a code-reuse perspective, but the instant that function breaks, prevention and detection die &lt;strong&gt;simultaneously&lt;/strong&gt;. A hidden single point of failure. At least one of them needs to verify with different material (post-count delta, API count).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Success checks that depend on the vendor's fixed wording.&lt;/strong&gt; TikTok navigated to a different page after posting and the sidebar label changed from 「管理する」 to 「管理」 — that alone broke all five registered strings. &lt;code&gt;post_reel_tiktok.py&lt;/code&gt; threw &lt;code&gt;RuntimeError&lt;/code&gt; on every post and stacked &lt;code&gt;ok:false&lt;/code&gt; for 15 days. Only opening the TikTok dashboard directly revealed that everything had posted.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Failure exceptions with no page URL and no body prefix.&lt;/strong&gt; One line — &lt;code&gt;RuntimeError: タイムアウト: 成功文言が見つかりません&lt;/code&gt; — can't diagnose "TikTok changed its wording." Because &lt;code&gt;page.url&lt;/code&gt; and the first 200 characters of &lt;code&gt;body&lt;/code&gt; weren't in the exception, isolating the cause took 15 days.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Playwright strict mode throwing on multiple elements.&lt;/strong&gt; An X (formerly Twitter) quote post's article element has two &lt;code&gt;[data-testid="tweetText"]&lt;/code&gt; nodes — my own text and the embedded original. &lt;code&gt;innerText()&lt;/code&gt; threw in strict mode, returned an empty string, the comparison fell to &lt;code&gt;unverified&lt;/code&gt;, and every quote post since September 5 was counted as &lt;code&gt;quoted=0&lt;/code&gt;. One added &lt;code&gt;.first()&lt;/code&gt; fixed it, but it took five days to notice.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A design that mistranslates "couldn't verify" as "not posted."&lt;/strong&gt; Fall back to the DOM when the API is down and you're back to &lt;code&gt;inner_text&lt;/code&gt; matching. "The API returned JSON but the post isn't found" is definitive (not posted); "none of 8 attempts returned JSON" just means it couldn't be verified. Escape to the DOM without distinguishing the two and the false-negative recurrence path stays open.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Throwing the same failure three times with more attempts.&lt;/strong&gt; note-autolike's rewrite produced zero output for three days because the validator rejected titles over 58 characters while the generation prompt said nothing about a length limit. &lt;code&gt;askClaude&lt;/code&gt; simply re-sent the identical prompt three times on failure, and got the same 63-character title three times. A constraint has to be written in &lt;strong&gt;both&lt;/strong&gt; the validator and the generation prompt to function.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Exiting 0 immediately on slot-acquisition failure.&lt;/strong&gt; &lt;code&gt;browser-slot.sh&lt;/code&gt; printed &lt;code&gt;SKIP: global limit reached&lt;/code&gt; and exited the instant it couldn't grab one of the global slots (3), so 12 jobs in the 12:00 hour, 9 in the 09:00 hour, and 8 in the 11:00 hour vanished without ever starting. After adding a 600-second wait option and randomizing the retry interval to 15–45 seconds, misfires dropped to nearly zero. A fixed interval makes every job stampede at the same instant (thundering herd), so the random spread is mandatory.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A fallback that lands on the same wall.&lt;/strong&gt; In &lt;code&gt;gen_note_thumbs.py&lt;/code&gt; I added a three-tier fallback — real Chrome → bundled chromium → env — and all three tiers ended in the same 90-second &lt;code&gt;rc=-9&lt;/code&gt;. Same symptom doesn't mean same cause. Before wiring in a fallback, measure once whether that fallback hits a different wall than the original.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;IG's delete API returning 200 without deleting.&lt;/strong&gt; Call the delete API with an expired session (&lt;code&gt;sessionid&lt;/code&gt;) and it returns 200 with an HTML body. Trying to delete the three August 28 duplicates, not one was removed. Don't define success as "the API returned 200" — the exact same lesson as post verification.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Best-effort pass-through that only surfaces downstream.&lt;/strong&gt; In IG reel crop-ratio selection, there was a branch that silently passed through when &lt;code&gt;query_selector&lt;/code&gt; returned None. The label varied by day — 「オリジナル」, 「Original」, 「9:16」, 「元の写真」, 「元の比率」 — while the code's candidate list was fixed. 9:16 wasn't selected, "Next" was pressed at 1:1, and a video with the sides cropped off went public on the grid. Select/toggle operations need &lt;strong&gt;measured confirmation&lt;/strong&gt; that the selection took before moving to the next step.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Trusting the note CTA paste result.&lt;/strong&gt; On September 2, note.com changed its behavior to drop quote blocks. The script verified by matching a signature string at the end, so it judged the stripped articles as "not applied" and kept re-pasting — producing 59 duplicate CTAs and 31 missing signatures. It should have verified by reading back the published page, not the editor's "inserted" record.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Reproducing the same-shaped bug on another platform.&lt;/strong&gt; I'd learned the structure "DOM goes from one element to multiple → strict comparison falls to failure → false negative" on TikTok. Two weeks later I hit the exact same structure on X. The result of having no habit of preemptively fixing same-shaped spots across all lanes at fix time. Checking a pattern learned in one place across the board would have closed it in 0 days, not 5.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Best practices
&lt;/h2&gt;

&lt;p&gt;Here are the patterns actually in use in my environment now, distilled from the pitfalls above.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Always pass both sides of a match key through the same normalization function&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;normalize_caption_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;unicodedata&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isspace&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;ord&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mh"&gt;0xFFFF&lt;/span&gt;
        &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;unicodedata&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;category&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;So&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Sk&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the same function over both the caption string and the DOM-extracted text. One side alone leaves the post-transform lengths out of sync and produces a new mismatch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Turn an empty match key into an error at the entrance&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;照合キーが空: 投稿前に中止してください&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An empty key isn't "no match (ok:false)" — it's the third state, "the comparison never happened." Stack it as &lt;code&gt;ok:false&lt;/code&gt; in the ledger and the next retry fires, multiplying side effects.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Verify side-effecting operations against the other side's actual state&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_verify_via_api&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;shortcode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;caption&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_fetch_profile_api&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;DS_USER_ID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;...)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;post&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;edges&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]):&lt;/span&gt;
            &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;normalize_caption_key&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="nf"&gt;normalize_caption_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;caption&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;source&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;api&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;source&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;api&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reason&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;not_found&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_verify_via_dom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;caption&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"The API returned JSON but the post isn't found" is definitive — don't escape to the DOM. Only when none of 8 attempts returns JSON, use the DOM exactly once. "Couldn't verify" and "not posted" are different states.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Fail closed — treat "unverifiable" as "already posted"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The damage from a duplicate (cleanup work, time spent public) and from a delay (absorbed by the next job) is asymmetric. When you can't verify, pick the lighter one and record &lt;code&gt;{"ok": True, "note": "unverified-assumed-live"}&lt;/code&gt; in the ledger. Recording &lt;code&gt;ok:true&lt;/code&gt; is what prevents the next retry.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Use different material for "prevention" and "detection"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If the pre-post duplicate guard and the post-post landing check share the same 24-character key, one breaking kills both. Run the post-count delta (difference in profile post count before and after) as an independent verification material. With caption and count moving independently, one breaking doesn't kill the verdict completely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Judge caption insertion by the field's actual character count&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;insert_caption_with_verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;caption&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;method&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;_keyboard_insert&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_clipboard_paste&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_js_set_value&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="nf"&gt;method&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;caption&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;actual&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;[data-lexical-editor]&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;inner_text&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;actual&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;caption&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;キャプション投入失敗: 期待&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;caption&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;文字/実測&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;actual&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;文字&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"It went in" is decided by the field's actual character count, not the API's return value. Try three methods and verify by reading back — in a live run it printed &lt;code&gt;キャプション投入 1257文字 / 期待1257文字&lt;/code&gt; and then passed landing verification in 13 seconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;7. Judge success by the text you submitted, not vendor wording&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_is_posted_successfully&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;caption&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;inner_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;SUCCESS_TEXTS&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/tiktokstudio/content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;normalize_caption_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;caption&lt;/span&gt;&lt;span class="p"&gt;)[:&lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;inner_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check 3 — "verify using the caption I submitted" — is the hardest to break. The vendor can change its UI, but the text I posted will be on the screen. Fixed strings are the fallback.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;8. Always attach &lt;code&gt;page.url&lt;/code&gt; and the first 200 characters of body to failure exceptions&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When a vendor-wording-dependent check breaks, diagnosis takes 15 days if there's no evidence at the scene. Always put &lt;code&gt;page.url&lt;/code&gt; and &lt;code&gt;page.inner_text("body")[:200]&lt;/code&gt; in the exception's one line.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;9. Use &lt;code&gt;.first()&lt;/code&gt; explicitly under Playwright strict mode, and add it preemptively across all lanes&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 変更前: strict modeでthrow&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tweetText&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[data-testid="tweetText"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;innerText&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="c1"&gt;// 変更後&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tweetText&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;article&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;locator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[data-testid="tweetText"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;first&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;innerText&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At fix time, check &lt;strong&gt;every lane&lt;/strong&gt; using the same kind of comparison and apply the same fix. This is the habit that prevents "learned it in one place, recurred on another platform."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;10. When the same failure happens three times, suspect what you're passing in, not the count&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Exactly as written in &lt;code&gt;retry-and-giveup-design.md&lt;/code&gt; — &lt;strong&gt;"Throwing the same failure three times doesn't change the result. Suspect what you're passing in, not the count."&lt;/strong&gt; Deterministic failures (validation violations, expired logins, vendor wording changes) must be classified before retrying, or adding attempts just burns resources.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;11. Set retry counts by "how many times may this side effect occur?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Attaching three retries to a non-idempotent operation declares that you'll tolerate the side effect up to three times. Before designing retries for social posts, ask "is this operation idempotent?" If not, always insert a step that checks the current state before retrying.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;12. Wait up to 600 seconds for a slot instead of skipping immediately&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;BROWSER_SLOT_WAIT_SEC&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;600  &lt;span class="c"&gt;# 0にすれば従来の即skip&lt;/span&gt;
&lt;span class="c"&gt;# 再試行間隔はランダム（thundering herd 防止）&lt;/span&gt;
&lt;span class="nb"&gt;sleep&lt;/span&gt; &lt;span class="k"&gt;$((&lt;/span&gt;RANDOM &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="m"&gt;30&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="m"&gt;15&lt;/span&gt;&lt;span class="k"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;BROWSER_SLOT_WAIT_SEC=0&lt;/code&gt; exactly matches the old immediate-skip behavior, preserving backward compatibility with the 30+ existing launchd jobs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;13. Distinguish "waited / gave up / skipped" in the ledger log&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If a record that gave up after &lt;code&gt;waited=600s&lt;/code&gt; and a record that did nothing and hit &lt;code&gt;exit 0&lt;/code&gt; look like the same line, you can't tell "inefficient" from "never ran at all." To judge from the ledger whether a run of false negatives means "the comparator is broken" or "it genuinely isn't running," always log the reason.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;14. When you add a retry, put fault injection in the same commit&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 本番では無効、注入時だけ有効&lt;/span&gt;
&lt;span class="nv"&gt;WA_API_FAIL_ONCE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2 node index.js
&lt;span class="c"&gt;# → "api retry attempt=1/3" → "attempt=2/3" → exit 0 で完走&lt;/span&gt;
&lt;span class="c"&gt;# 未設定なら retry ログが1行も出ない&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Claiming you "added" a retry without fault injection proves nothing, because intermittent failures can't be reproduced and you can't show the path was actually exercised. Don't stop at showing the &lt;code&gt;git diff&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;15. Read back the published page before recording "applied"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Don't trust the editor's "inserted" return value. The 90 duplicate note CTAs happened because this verification step was missing. The principle &lt;code&gt;reader-state-not-self-record&lt;/code&gt; — judge by the other side's actual state, not your own send log — applies to both post verification and paste verification.&lt;/p&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;In roughly two weeks starting August 28, three platforms broke the same way in succession. IG's 3 duplicates, TikTok's 15 consecutive exit 1s, X quote posts at zero for 5 days — every one has the structure "the comparator works fine, but the material it compares is broken."&lt;/p&gt;

&lt;p&gt;The script throws no errors. The ledger keeps stacking &lt;code&gt;ok:false&lt;/code&gt;, and the scheduler calmly books re-runs. Nowhere is there a signal saying "the job is broken." You only notice when you look at reality directly from outside — three carousels lined up on the profile, a post list checked on the dashboard, a ledger finally opened after a week of zero-count notifications.&lt;/p&gt;

&lt;p&gt;One line from &lt;code&gt;retry-and-giveup-design.md&lt;/code&gt; sums up this structure:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Throwing the same failure three times doesn't change the result. Suspect what you're passing in, not the count.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In my environment now, when landing verification fails, the next step isn't an immediate retry but asking "why did it fail?" through a separate path. Hit the API directly to confirm the post exists; if it does, record it as &lt;code&gt;already-live&lt;/code&gt; with &lt;code&gt;ok:true&lt;/code&gt;. Only if it doesn't is "genuinely not posted" confirmed.&lt;/p&gt;

&lt;p&gt;That one extra step — verify, then decide — is the precondition for running 171 jobs autonomously. Building an environment means driving the number of times I have to touch anything toward zero. Keeping it at zero means continually asking what the verdict is actually based on. The comparator doesn't break. What breaks is the material the comparator is looking at.&lt;/p&gt;

&lt;p&gt;One question for you: in your own automation, which success check is currently trusting a return value instead of reading back the other side's actual state?&lt;/p&gt;




&lt;p&gt;The full picture of the system, the breakdown of the ¥1.2M/month, and the 30-day setup guide are in a paid note (Japanese).&lt;br&gt;
📕 &lt;a href="https://note.com/bokuwalily/n/n849b3a07784a" rel="noopener noreferrer"&gt;How I actually make money with an autonomous Claude Code environment — system, examples, getting started, support&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by **Lily&lt;/em&gt;* — I ship iOS apps and automate my content stack with Claude Code.&lt;br&gt;
Follow along: &lt;a href="https://bokuwalily.com" rel="noopener noreferrer"&gt;Portfolio&lt;/a&gt; · &lt;a href="https://x.com/bokuwalily" rel="noopener noreferrer"&gt;X&lt;/a&gt; · &lt;a href="https://github.com/bokuwalily" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;*&lt;/p&gt;

</description>
      <category>automation</category>
      <category>claudecode</category>
      <category>debugging</category>
      <category>python</category>
    </item>
    <item>
      <title>--incremental Made My TypeScript Hook 3.6x Slower. A 200KB Threshold Fixed It</title>
      <dc:creator>Lily</dc:creator>
      <pubDate>Thu, 17 Sep 2026 00:00:08 +0000</pubDate>
      <link>https://dev.to/bokuwalily/-incremental-made-my-typescript-hook-36x-slower-a-200kb-threshold-fixed-it-ed4</link>
      <guid>https://dev.to/bokuwalily/-incremental-made-my-typescript-hook-36x-slower-a-200kb-threshold-fixed-it-ed4</guid>
      <description>&lt;p&gt;I started freelancing in college at ¥100k a month, stacked side gigs up to ¥600k, then got laid off and dropped to zero overnight. Over the next six months I built an autonomous Claude Code setup from scratch, and it now runs at ¥1.2M a month in revenue. Of everything that went into that, the bug that quietly ate the most time was the one where I tried to make something faster and made it slower instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Setup Works
&lt;/h2&gt;

&lt;h3&gt;
  
  
  "Working" and "the environment is running" are two different things
&lt;/h3&gt;

&lt;p&gt;When you're trying to grow revenue as a solo developer, the first wall you hit isn't how fast you write code — it's &lt;strong&gt;the lag in verification&lt;/strong&gt;. If you catch a type error the instant you write it, fixing it takes a minute. If you catch it 30 seconds later, you have to reload the surrounding context from memory. At 60 seconds you're already drifting into the next task, so the context switch costs even more.&lt;/p&gt;

&lt;p&gt;Claude Code has a mechanism that calls a PostToolUse hook after every tool execution. Put &lt;code&gt;tsc --noEmit&lt;/code&gt; there and a type check runs every time the agent edits a file; if there's an error, feedback flows straight back into the next instruction. The structure closes the "write a file, then verify it" loop without a human in the middle.&lt;/p&gt;

&lt;p&gt;The problem is that as the project grows, that "straight back" falls apart.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why tsc's cold start is so heavy
&lt;/h3&gt;

&lt;p&gt;The TypeScript compiler loads the entire dependency graph on startup. With 100 files it's snappy, but once imports chain out and the set of modules actually referenced balloons into the hundreds, re-parsing from zero every time is no small cost. When I measured it on a project called &lt;code&gt;closet-os&lt;/code&gt;, a cold &lt;code&gt;tsc --noEmit&lt;/code&gt; took 30–60 seconds. An environment where the hook blocks for 60 seconds on every tool call isn't autonomous — it's an obstacle that stops work.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;--incremental&lt;/code&gt; option exists to solve this. It does the full analysis only on the first run and caches the result in a &lt;code&gt;.tsbuildinfo&lt;/code&gt; file. Subsequent runs re-analyze only the diff, so if the change is small it finishes in &lt;strong&gt;1–3 seconds&lt;/strong&gt;. For a hook, it looked like the ideal choice.&lt;/p&gt;

&lt;h3&gt;
  
  
  The case where it backfires
&lt;/h3&gt;

&lt;p&gt;But when I added &lt;code&gt;--incremental&lt;/code&gt; to the &lt;code&gt;closet-os&lt;/code&gt; hook, cold runs got &lt;strong&gt;3.6x slower&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The cause was the &lt;code&gt;.tsbuildinfo&lt;/code&gt; file bloating. The bigger the project, the longer the initial cache generation for incremental diff data takes. On top of that, as the cache accumulates and the file size grows, the overhead of reading that giant JSON on every run stops being negligible. The "only process the diff" benefit was being outweighed by the "load a heavy cache every time" cost.&lt;/p&gt;

&lt;p&gt;For small-to-medium projects, incremental wins overwhelmingly. But on large projects, plain &lt;code&gt;--noEmit&lt;/code&gt; is actually faster — the relationship inverts. And since the hook reuses the same script across every project, I needed "a mechanism that switches automatically based on project size."&lt;/p&gt;

&lt;h3&gt;
  
  
  The threshold guard idea
&lt;/h3&gt;

&lt;p&gt;The cache file's size correlates strongly with project size. Small &lt;code&gt;.tsbuildinfo&lt;/code&gt; means incremental works; large means normal mode is faster. From that observation I arrived at a one-line guard: "&lt;strong&gt;if the cache file exceeds 200KB, fall back to normal mode&lt;/strong&gt;."&lt;/p&gt;

&lt;p&gt;The threshold was set from measurements. I confirmed both that the &lt;code&gt;closet-os&lt;/code&gt; cache was around 200KB at the point where it started getting slow, and that projects smaller than that were consistently faster with incremental. It's not a magic number; it's a boundary based on real data.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Overall Flow
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The full picture of the hook
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Claude Code がファイルを編集 (PostToolUse)
        │
        ▼
post_tsc_check.sh 起動
        │
        ├─ tsconfig.json が無い → exit 0（即終了）
        │
        ├─ timeout コマンド解決
        │    ├─ gtimeout (GNU coreutils) があれば優先
        │    └─ なければ timeout / フォールバック
        │
        ├─ TSBUILDINFO のサイズ確認
        │    ├─ ファイル無し or ≤200KB → --incremental モード
        │    └─ &amp;gt;200KB             → 通常モード（フォールバック）
        │
        ├─ tsc 実行（timeout 60s）
        │    ├─ --incremental: --noEmit --pretty false --incremental --tsBuildInfoFile
        │    └─ 通常:           --noEmit --pretty false
        │
        ├─ rc=124 (timeout) → 警告メッセージ・exit 0
        └─ エラーあり → 型エラー表示・exit 0（エージェントはログを受け取る）
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Claude Code agent can use the hook's output directly in its next reasoning step. When there are type errors they're printed with the prefix "=== TypeScript型エラー検出 ===" (TypeScript type errors detected), so the agent reads the errors itself and enters a fix loop.&lt;/p&gt;

&lt;h3&gt;
  
  
  The real code: from size check to branching
&lt;/h3&gt;

&lt;p&gt;Let's read the core of &lt;code&gt;~/.claude/hooks/post_tsc_check.sh&lt;/code&gt; in order.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;① Defining the cache path&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;CACHE_DIR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"node_modules/.cache"&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"node_modules"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CACHE_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null
&lt;span class="nv"&gt;TSBUILDINFO&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CACHE_DIR&lt;/span&gt;&lt;span class="s2"&gt;/tsc-hook.tsbuildinfo"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;.tsbuildinfo&lt;/code&gt; is pinned at &lt;code&gt;node_modules/.cache/tsc-hook.tsbuildinfo&lt;/code&gt;. The reason it's not in the project root is that &lt;code&gt;node_modules/&lt;/code&gt; in &lt;code&gt;.gitignore&lt;/code&gt; already covers it. The filename &lt;code&gt;tsc-hook&lt;/code&gt; also keeps its namespace separate from the app's own build cache.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;② The 200KB guard&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;USE_INCREMENTAL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TSBUILDINFO&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nv"&gt;SIZE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;stat&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; %z &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TSBUILDINFO&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo &lt;/span&gt;0&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;SIZE&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="nv"&gt;0&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-gt&lt;/span&gt; 204800 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nv"&gt;USE_INCREMENTAL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
  &lt;span class="k"&gt;fi
fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;stat -f %z&lt;/code&gt; is how you get a file's byte count with macOS's &lt;code&gt;stat&lt;/code&gt; (the equivalent of Linux's &lt;code&gt;stat -c %s&lt;/code&gt;). &lt;code&gt;2&amp;gt;/dev/null || echo 0&lt;/code&gt; provides a zero fallback on error, so it runs safely on the first invocation when the file doesn't exist yet. &lt;code&gt;204800&lt;/code&gt; is &lt;code&gt;200 × 1024&lt;/code&gt;, i.e. 200KB in bytes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;③ Building the arguments and running&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$USE_INCREMENTAL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; 1 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nv"&gt;TSC_ARGS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"--noEmit --pretty false --incremental --tsBuildInfoFile &lt;/span&gt;&lt;span class="nv"&gt;$TSBUILDINFO&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;else
  &lt;/span&gt;&lt;span class="nv"&gt;TSC_ARGS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"--noEmit --pretty false"&lt;/span&gt;
&lt;span class="k"&gt;fi

if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TIMEOUT_CMD&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nv"&gt;result&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nv"&gt;$TIMEOUT_CMD&lt;/span&gt; npx tsc &lt;span class="nv"&gt;$TSC_ARGS&lt;/span&gt; 2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-30&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nv"&gt;rc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;
&lt;span class="k"&gt;else
  &lt;/span&gt;&lt;span class="nv"&gt;result&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;npx tsc &lt;span class="nv"&gt;$TSC_ARGS&lt;/span&gt; 2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-30&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nv"&gt;rc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--pretty false&lt;/code&gt; turns off color codes. The hook's output goes into the agent's log, not a terminal, so ANSI escape sequences mixed in would hurt readability. &lt;code&gt;head -30&lt;/code&gt; prevents an output explosion when errors pour out in bulk.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;④ macOS support for timeout&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;TIMEOUT_CMD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="nb"&gt;command&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; gtimeout &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nv"&gt;TIMEOUT_CMD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"gtimeout 60"&lt;/span&gt;
&lt;span class="k"&gt;elif &lt;/span&gt;&lt;span class="nb"&gt;command&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nb"&gt;timeout&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nv"&gt;TIMEOUT_CMD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"timeout 60"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;macOS has no &lt;code&gt;/usr/bin/timeout&lt;/code&gt; (on the BSD side, &lt;code&gt;timeout&lt;/code&gt; comes as &lt;code&gt;gtimeout&lt;/code&gt; from Homebrew's &lt;code&gt;coreutils&lt;/code&gt;). We check for existence with &lt;code&gt;command -v&lt;/code&gt; before assigning, and if neither exists, &lt;code&gt;TIMEOUT_CMD=""&lt;/code&gt; stays empty and it runs without a timeout. Written in this order, the same script works on Linux, macOS, and CI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;⑤ Detecting a timeout&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$rc&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; 124 &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"=== TypeScript check timeout (60s exceeded — tsc 多重実行/巨大依存変更の疑い) ==="&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;rc=124&lt;/code&gt; is the exit code when &lt;code&gt;timeout&lt;/code&gt; / &lt;code&gt;gtimeout&lt;/code&gt; kills the process. It's treated as a warning rather than an error, and returning &lt;code&gt;exit 0&lt;/code&gt; doesn't block the agent's flow. The message mentions "suspected concurrent execution" because it actually happened: when Claude Code runs tools in parallel, multiple &lt;code&gt;tsc&lt;/code&gt; processes start at the same time and corrupt each other's cache.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why return exit 0
&lt;/h3&gt;

&lt;p&gt;Even with type errors, the script ends with &lt;code&gt;exit 0&lt;/code&gt;. With &lt;code&gt;exit 1&lt;/code&gt;, the hook is treated as "failed," and there are cases where Claude Code's tool execution itself gets interrupted. Type errors are &lt;strong&gt;information&lt;/strong&gt; for the agent, not a condition for halting the flow. If you write the error content to stdout, the agent reasons "there's a type error, so fix it" in its next step. It's a design that separates error detection from flow control.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation Details (continued)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why the tsconfig.json check is the first line
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"tsconfig.json"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The script begins with this one line. PostToolUse hooks fire in every project, so they trigger in repositories that aren't TypeScript at all. Putting the &lt;code&gt;tsconfig.json&lt;/code&gt; existence check at the top means Python projects or Bash-only directories exit immediately with &lt;code&gt;exit 0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The key point is that this check runs in the direction of "exit if &lt;code&gt;tsconfig.json&lt;/code&gt; is &lt;strong&gt;absent&lt;/strong&gt;," not "exit on failure." When &lt;code&gt;[ -f "tsconfig.json" ]&lt;/code&gt; is true, &lt;code&gt;exit 0&lt;/code&gt; doesn't run (the right side of &lt;code&gt;||&lt;/code&gt; is evaluated only when the left side is false). If you're not used to reading this one-liner it'll confuse you later, so it's worth sorting out up front.&lt;/p&gt;

&lt;h3&gt;
  
  
  Designing the output block
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$result&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"=== TypeScript型エラー検出 ==="&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$result&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"================================"&lt;/span&gt;
&lt;span class="k"&gt;fi
&lt;/span&gt;&lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hook "prints nothing" when there are zero errors — that is, the &lt;strong&gt;normal case&lt;/strong&gt;. If all types pass, stdout is empty and no noise lands in the agent's log. Only when there are errors does it print with the &lt;code&gt;=== TypeScript型エラー検出 ===&lt;/code&gt; prefix, so the agent can determine whether type errors exist simply by checking whether that string appears in the output.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;-n "$result"&lt;/code&gt; checks that the string is non-empty. With no type errors, &lt;code&gt;tsc&lt;/code&gt;'s output is an empty string, so this condition works as the equivalent of "type errors present = output present."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;2&amp;gt;&amp;amp;1 | head -30&lt;/code&gt; combination&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;result&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nv"&gt;$TIMEOUT_CMD&lt;/span&gt; npx tsc &lt;span class="nv"&gt;$TSC_ARGS&lt;/span&gt; 2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-30&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;2&amp;gt;&amp;amp;1&lt;/code&gt; handles the case where &lt;code&gt;tsc&lt;/code&gt; writes errors to stderr. TypeScript errors normally go to stderr. Without merging them into stdout, &lt;code&gt;$result&lt;/code&gt; gets nothing. &lt;code&gt;head -30&lt;/code&gt; limits output to the top 30 lines so the buffer doesn't overflow when errors are explosively numerous (cascading type errors producing hundreds of lines is not unusual).&lt;/p&gt;

&lt;p&gt;Is there a basis for the number 30? There is. Looking at actual error logs from &lt;code&gt;closet-os&lt;/code&gt;, the core of the error content (filename, line number, message) is concentrated in the first few lines. Thirty lines was enough for the agent to make a fix decision. Too many, and a single error consumes hundreds of tokens, degrading the hook's cost efficiency.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why use &lt;code&gt;npx tsc&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;I use &lt;code&gt;npx tsc&lt;/code&gt; rather than calling &lt;code&gt;./node_modules/.bin/tsc&lt;/code&gt; directly. The reason is simple: even when &lt;code&gt;node_modules&lt;/code&gt; doesn't exist (before the first &lt;code&gt;npm install&lt;/code&gt;), &lt;code&gt;npx&lt;/code&gt; falls back to a global &lt;code&gt;tsc&lt;/code&gt;. The hook needs to run regardless of the project's setup state. When &lt;code&gt;node_modules&lt;/code&gt; does exist, &lt;code&gt;npx&lt;/code&gt; prefers the local one, so there's no version discrepancy.&lt;/p&gt;

&lt;h3&gt;
  
  
  Total line count of the script
&lt;/h3&gt;

&lt;p&gt;The actual file is 61 lines. Excluding comments and blank lines, it's effectively under 40. "The clarity of 100 lines over 1,000" is something you can embody in places like this. The shorter the functional core, the easier it is to copy-paste when porting to another project later.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where I Got Stuck
&lt;/h2&gt;

&lt;p&gt;Everything below is a failure I actually hit. Symptom, cause, fix — in that order.&lt;/p&gt;

&lt;h3&gt;
  
  
  ① Adding a pipe made &lt;code&gt;rc&lt;/code&gt; always 0
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Symptom&lt;/strong&gt;: &lt;code&gt;tsc&lt;/code&gt; is emitting type errors, but no error message ever comes back from the hook. Adding &lt;code&gt;echo "$result"&lt;/code&gt; for debugging shows the output is captured correctly, yet &lt;code&gt;rc&lt;/code&gt; stays 0.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause&lt;/strong&gt;: The first implementation was written like this.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;result&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;npx tsc &lt;span class="nv"&gt;$TSC_ARGS&lt;/span&gt; 2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-30&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;rc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;$?&lt;/code&gt; captures &lt;strong&gt;the exit code of the immediately preceding command&lt;/strong&gt;. In a pipeline, &lt;code&gt;$?&lt;/code&gt; returns the exit code of &lt;code&gt;head&lt;/code&gt;. &lt;code&gt;head -30&lt;/code&gt; always succeeds (exit code 0), so no matter how many errors &lt;code&gt;tsc&lt;/code&gt; emitted, &lt;code&gt;rc=0&lt;/code&gt; persisted.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix&lt;/strong&gt;: I considered avoiding the pipe by capturing all output first and then running it through &lt;code&gt;head&lt;/code&gt;, but that has buffer problems with massive errors. Currently the variables are two-tiered (&lt;code&gt;result&lt;/code&gt; holds the &lt;code&gt;head&lt;/code&gt;-filtered output, and &lt;code&gt;rc&lt;/code&gt; looks at the exit code of the &lt;code&gt;timeout&lt;/code&gt; command rather than immediately after the pipe). If you look at the actual script, &lt;code&gt;rc=$?&lt;/code&gt; comes after the whole assignment, not after &lt;code&gt;| head -30&lt;/code&gt;. As shell behavior goes, the exit code of a command substitution &lt;code&gt;$(...)&lt;/code&gt; is that of the last command in the pipeline. &lt;code&gt;timeout&lt;/code&gt; / &lt;code&gt;gtimeout&lt;/code&gt; returns 124 only on timeout and otherwise passes &lt;code&gt;tsc&lt;/code&gt;'s exit code through transparently. In other words, by inserting &lt;code&gt;timeout&lt;/code&gt;, the only thing inside the pipe is &lt;code&gt;head&lt;/code&gt;, and &lt;code&gt;timeout&lt;/code&gt; carries out &lt;code&gt;tsc&lt;/code&gt;'s code.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;result&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nv"&gt;$TIMEOUT_CMD&lt;/span&gt; npx tsc &lt;span class="nv"&gt;$TSC_ARGS&lt;/span&gt; 2&amp;gt;&amp;amp;1 | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-30&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;rc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;
&lt;span class="c"&gt;# この rc は timeout コマンド全体の終了コード&lt;/span&gt;
&lt;span class="c"&gt;# timeout が tsc を wrap しているので、tsc の rc が透過される&lt;/span&gt;
&lt;span class="c"&gt;# (ただし timeout 自体の rc=124 がタイムアウトを示す)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I wasn't using &lt;code&gt;timeout&lt;/code&gt; at first, which is how I fell into this trap. I only realized afterward that adding &lt;code&gt;timeout&lt;/code&gt; had also become a side solution to the pipe problem.&lt;/p&gt;

&lt;h3&gt;
  
  
  ② Getting the size failed with macOS &lt;code&gt;stat&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Symptom&lt;/strong&gt;: I implemented the 200KB guard, but &lt;code&gt;USE_INCREMENTAL=1&lt;/code&gt; never changes regardless of state. The &lt;code&gt;tsbuildinfo&lt;/code&gt; has clearly grown to several MB, yet no fallback.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause&lt;/strong&gt;: I'd been reading Linux command references and wrote &lt;code&gt;stat -c %s&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 間違い（Linux 用）&lt;/span&gt;
&lt;span class="nv"&gt;SIZE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;stat&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; %s &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TSBUILDINFO&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo &lt;/span&gt;0&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;macOS's &lt;code&gt;stat&lt;/code&gt; is BSD-based and the options are completely different. &lt;code&gt;-c %s&lt;/code&gt; is treated as an invalid option on macOS and returns an error. Since &lt;code&gt;2&amp;gt;/dev/null&lt;/code&gt; discards the error, &lt;code&gt;echo 0&lt;/code&gt; runs, and the size was always judged to be 0.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix&lt;/strong&gt;: The macOS syntax is &lt;code&gt;-f %z&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;SIZE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;stat&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; %z &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TSBUILDINFO&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo &lt;/span&gt;0&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-f&lt;/code&gt; specifies a format string, and &lt;code&gt;%z&lt;/code&gt; returns the file size in bytes. Same meaning as Linux's &lt;code&gt;-c %s&lt;/code&gt;, different flag.&lt;/p&gt;

&lt;p&gt;Discovering this required reading &lt;code&gt;man stat&lt;/code&gt; and cost me 30 minutes. I've thought I should have just used &lt;code&gt;wc -c &amp;lt; "$TSBUILDINFO"&lt;/code&gt; from the start, but &lt;code&gt;wc -c&lt;/code&gt; reads the whole file, which is slightly slower on large files, and it needs extra error handling when the file doesn't exist. &lt;code&gt;stat&lt;/code&gt; only reads filesystem metadata so it's faster, and the &lt;code&gt;2&amp;gt;/dev/null || echo 0&lt;/code&gt; pattern handles absence safely in one line.&lt;/p&gt;

&lt;h3&gt;
  
  
  ③ &lt;code&gt;exit 1&lt;/code&gt; stopped the agent
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Symptom&lt;/strong&gt;: Every time the hook detects a type error and runs, Claude Code's tool execution loop gets interrupted midway. I can see errors are being emitted, but the loop ends before the agent starts reasoning "let me fix this."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause&lt;/strong&gt;: The first implementation returned &lt;code&gt;exit 1&lt;/code&gt; on type errors. When a PostToolUse hook returns a non-zero exit code, Claude Code treats the tool execution as "failed," and there are cases where it doesn't continue subsequent steps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix&lt;/strong&gt;: I switched to a design that limits the hook's role to "information notification," not "flow control." Type errors are &lt;strong&gt;input information&lt;/strong&gt; for the agent, not a reason to stop processing. By writing the error content to stdout and returning &lt;code&gt;exit 0&lt;/code&gt;, the agent receives the information while continuing, and proceeds autonomously to the next action: "fix this type error."&lt;/p&gt;

&lt;p&gt;Once I reframed it as "the hook is an information channel, not a control channel," the design cleaned right up.&lt;/p&gt;

&lt;h3&gt;
  
  
  ④ ANSI escape codes polluted the agent's context
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Symptom&lt;/strong&gt;: The agent should be receiving the type error log, but it can't read the error content accurately and produces off-target fixes. When I paste the error message into my own terminal, mysterious strings like &lt;code&gt;^[[1m^[[31merror^[[0m&lt;/code&gt; are mixed in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause&lt;/strong&gt;: There was a period when I hadn't added &lt;code&gt;--pretty false&lt;/code&gt;. TypeScript's default is colored output, which shows as red error text in a terminal, but the substance is ANSI escape sequences. When these get mixed into the hook's output, the text the agent receives differs from what a human reads. LLMs don't interpret escape sequences reliably, and control codes wedged into "the position where the word &lt;code&gt;error&lt;/code&gt; should be" were blocking recognition.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;TSC_ARGS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"--noEmit --pretty false ..."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Just add &lt;code&gt;--pretty false&lt;/code&gt; to the arguments. This makes tsc's output plain text. A good rule to remember: always add &lt;code&gt;--pretty false&lt;/code&gt; in hooks and CI environments. Terminal output is for humans to read; program-processed output doesn't need it.&lt;/p&gt;

&lt;h3&gt;
  
  
  ⑤ Concurrent launches corrupted &lt;code&gt;.tsbuildinfo&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Symptom&lt;/strong&gt;: After using the hook for a while, &lt;code&gt;tsc&lt;/code&gt; suddenly started emitting &lt;code&gt;Cannot read file 'node_modules/.cache/tsc-hook.tsbuildinfo'&lt;/code&gt;. Deleting the file and retrying fixes it, but it breaks again a few hours later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cause&lt;/strong&gt;: Claude Code sometimes executes multiple tools in parallel. When the agent issues "write file A" and "write file B" simultaneously, both PostToolUse hooks launch at the same time. When two &lt;code&gt;tsc --incremental&lt;/code&gt; processes try to write to the same &lt;code&gt;tsc-hook.tsbuildinfo&lt;/code&gt; file concurrently, the file ends up corrupted in a half-written state.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix&lt;/strong&gt;: The current script doesn't include full mutual exclusion (flock etc.). Instead, the policy is the 60-second timeout: "if multiple tsc processes run for a long time, let the OS reap them." The comment says as much: &lt;code&gt;# 並行 hook 実行による多重 tsc を防止&lt;/code&gt; (prevent multiple tsc from concurrent hook execution).&lt;/p&gt;

&lt;p&gt;The perfect solution is to use flock.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="o"&gt;(&lt;/span&gt;
  flock &lt;span class="nt"&gt;-x&lt;/span&gt; 200
  &lt;span class="c"&gt;# tsc 実行&lt;/span&gt;
&lt;span class="o"&gt;)&lt;/span&gt; 200&amp;gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TSBUILDINFO&lt;/span&gt;&lt;span class="s2"&gt;.lock"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But adding this means more verification on macOS (BSD &lt;code&gt;flock&lt;/code&gt; and GNU &lt;code&gt;flock&lt;/code&gt; behave differently). In current operation, if &lt;code&gt;.tsbuildinfo&lt;/code&gt; gets corrupted you delete it and it regenerates automatically next time, so I tolerate the loose timeout-based mitigation. It's a judgment call that prioritizes operational simplicity over precision.&lt;/p&gt;

&lt;p&gt;Even if &lt;code&gt;tsbuildinfo&lt;/code&gt; breaks, Claude Code itself doesn't stop. If the hook returns without type errors, it proceeds to the next step. At worst the impact is limited to "type checking is skipped for that one run." It's a concrete example of the principle that you don't need to complicate code for non-fatal errors.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stumbling Points (Real Landmines from Setup to Operation)
&lt;/h2&gt;

&lt;p&gt;The middle section covered five: "pipe makes rc always 0," "macOS stat syntax difference," "exit 1 stopped the agent," "ANSI code pollution," and "tsbuildinfo corruption from concurrent launches." Here's a rapid-fire list of everything else that's easy to trip over.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Forgetting &lt;code&gt;chmod +x&lt;/code&gt; makes the hook silently ignored&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Just placing the script doesn't make it run. Until you grant execute permission with &lt;code&gt;chmod +x ~/.claude/hooks/post_tsc_check.sh&lt;/code&gt;, Claude Code quietly skips the hook. No error or warning appears, so you sit in a state of "I configured the hook but no type check comes back." To verify, just run &lt;code&gt;ls -la ~/.claude/hooks/&lt;/code&gt; and check it shows &lt;code&gt;rwxr-xr-x&lt;/code&gt;. This is the most common cause of getting stuck on day one.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A &lt;code&gt;#!/bin/sh&lt;/code&gt; shebang means bash syntax won't parse in some places&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
The &lt;code&gt;${SIZE:-0}&lt;/code&gt; parameter expansion and &lt;code&gt;command -v&lt;/code&gt; work in &lt;code&gt;/bin/sh&lt;/code&gt; too, but the moment you add bash-only syntax while extending the script, it breaks. That's why the first line of the actual file is &lt;code&gt;#!/bin/bash&lt;/code&gt;. It's safest to use &lt;code&gt;#!/bin/bash&lt;/code&gt; from the start.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;If the current directory isn't the project root, the tsconfig.json check misses&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
PostToolUse hooks launch in the cwd at the time the agent executed the tool. If the agent is editing &lt;code&gt;packages/api/src/foo.ts&lt;/code&gt; and the cwd is &lt;code&gt;packages/api/&lt;/code&gt;, no problem — but if it's still at the root, it can't find the subpackage's &lt;code&gt;tsconfig.json&lt;/code&gt; and exits immediately with &lt;code&gt;exit 0&lt;/code&gt;. In a monorepo layout you need to reinforce this either by specifying the expected tsconfig path explicitly with &lt;code&gt;--project&lt;/code&gt;, or by doing &lt;code&gt;cd "$(git rev-parse --show-toplevel)"&lt;/code&gt; at the top of the hook to move to the root before running.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;In a monorepo, &lt;code&gt;tsc&lt;/code&gt; picks up an unexpected tsconfig.json&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
With multiple &lt;code&gt;tsconfig.json&lt;/code&gt; files scattered under &lt;code&gt;packages/&lt;/code&gt;, which one gets referenced changes with the cwd. Cases arise where you intended to use the root tsconfig but a subpackage's tsconfig gets picked up, shifting the scope of type error detection. Two options: specify an absolute path in &lt;code&gt;TSC_ARGS&lt;/code&gt; like &lt;code&gt;--project $(pwd)/tsconfig.json&lt;/code&gt;, or build a separate monorepo-aware hook.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Dropping &lt;code&gt;--noEmit&lt;/code&gt; generates js files on every hook run&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
If you delete &lt;code&gt;--noEmit&lt;/code&gt; while manually tweaking the incremental arguments, &lt;code&gt;tsc&lt;/code&gt; writes out &lt;code&gt;.js&lt;/code&gt; files. Build artifacts get overwritten on every hook, polluting &lt;code&gt;git status&lt;/code&gt;. Worse, the agent can enter an infinite loop: "file changed → hook fires → js generated → file changed…" Keep &lt;code&gt;--noEmit&lt;/code&gt; fixed at the front of the argument template.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Whitespace in the &lt;code&gt;TSBUILDINFO&lt;/code&gt; path splits the argument&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
If quotes are missing from &lt;code&gt;--tsBuildInfoFile $TSBUILDINFO&lt;/code&gt;, the moment the project path contains a space, the argument passed to &lt;code&gt;tsc&lt;/code&gt; splits and errors out. If you're using the script as-is without quotes, in an environment where the path contains spaces you need to add double quotes: &lt;code&gt;--tsBuildInfoFile "$TSBUILDINFO"&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;npx&lt;/code&gt; startup cost piles up in high-frequency sessions&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
&lt;code&gt;npx tsc&lt;/code&gt; resolves the existence of &lt;code&gt;node_modules/.bin/tsc&lt;/code&gt; before every run. The resolution itself is around 0.1–0.3 seconds, but when the agent edits dozens of files in one session, it adds up. In environments where &lt;code&gt;node_modules&lt;/code&gt; definitely exists, you have the option of pathing directly to &lt;code&gt;./node_modules/.bin/tsc&lt;/code&gt;. But that reduces resilience for the initial setup when &lt;code&gt;node_modules&lt;/code&gt; is absent, so it's not suited to scripts shared with CI.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Neither &lt;code&gt;gtimeout&lt;/code&gt; nor &lt;code&gt;timeout&lt;/code&gt; exists in the CI environment&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Minimal images like Alpine Linux sometimes don't include &lt;code&gt;timeout&lt;/code&gt;. &lt;code&gt;TIMEOUT_CMD=""&lt;/code&gt; stays empty and &lt;code&gt;tsc&lt;/code&gt; runs with no 60-second cutoff. CI type checks are usually managed in a dedicated job, but if you use the same script in CI, install &lt;code&gt;timeout&lt;/code&gt; beforehand with something like &lt;code&gt;RUN apk add --no-cache coreutils&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;code&gt;head -30&lt;/code&gt; truncates errors midway and the agent loses sight of the root cause&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
In projects with cascading type errors, the core is concentrated in the first 30 lines, but in cases like circular reference errors where "the root cause is further down," 30 lines can fall short. In that case, an effective approach is to combine it with an auxiliary hook that leaves the hook output in &lt;code&gt;/tmp/tsc-last.log&lt;/code&gt; via &lt;code&gt;tee&lt;/code&gt; and has the agent read the full thing with &lt;code&gt;cat /tmp/tsc-last.log&lt;/code&gt;. Keep the hook body simple, and supplement through a separate channel when information runs short — a separation-of-concerns mindset.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Applying the 200KB threshold uniformly to every project&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
200KB is a value derived from measurements on one specific project, &lt;code&gt;closet-os&lt;/code&gt;. Change the project size, dependency tree depth, or machine specs, and the inversion point changes too. Get the real numbers for your own project with the following commands.&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;  &lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-lh&lt;/span&gt; node_modules/.cache/tsc-hook.tsbuildinfo  &lt;span class="c"&gt;# キャッシュの現サイズ&lt;/span&gt;
  &lt;span class="nb"&gt;time &lt;/span&gt;npx tsc &lt;span class="nt"&gt;--noEmit&lt;/span&gt;                             &lt;span class="c"&gt;# 通常モードの実測&lt;/span&gt;
  &lt;span class="nb"&gt;time &lt;/span&gt;npx tsc &lt;span class="nt"&gt;--noEmit&lt;/span&gt; &lt;span class="nt"&gt;--incremental&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--tsBuildInfoFile&lt;/span&gt; node_modules/.cache/tsc-hook.tsbuildinfo  &lt;span class="c"&gt;# インクリメンタルの実測&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Take the cache size at the point where second-and-later incremental runs become "slower than normal mode" as your threshold, and you'll have a boundary value specific to your project.&lt;/p&gt;




&lt;h2&gt;
  
  
  Best Practices
&lt;/h2&gt;

&lt;p&gt;Here are the "I wish I'd done this from the start" principles that solidified through repeated implementation and failure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Write the tsconfig.json check on line one&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"tsconfig.json"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the defensive line that brings hook launch cost to zero in non-TypeScript projects. It's the precondition for everything that follows, so it always goes on the first line.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Never remove &lt;code&gt;--noEmit --pretty false&lt;/code&gt; — they're a set&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;--noEmit&lt;/code&gt; prevents file generation, and &lt;code&gt;--pretty false&lt;/code&gt; eliminates ANSI escape codes. Hook output becomes agent input, so machine-processed text doesn't need color codes. Remove either of these two options from the argument template and both quietly cause problems.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Always return &lt;code&gt;exit 0&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The hook's job is "to pass information," not "to stop processing." The moment you return a type error with &lt;code&gt;exit 1&lt;/code&gt;, the hook turns from an inspection tool into an obstacle. Write the error content to stdout and return &lt;code&gt;exit 0&lt;/code&gt;, and the agent receives the information while proceeding autonomously into a fix loop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Use &lt;code&gt;2&amp;gt;&amp;amp;1 | head -30&lt;/code&gt; as a set&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;tsc&lt;/code&gt; errors go to stderr. Without merging them into stdout via &lt;code&gt;2&amp;gt;&amp;amp;1&lt;/code&gt;, &lt;code&gt;$result&lt;/code&gt; gets nothing. &lt;code&gt;head -30&lt;/code&gt; is buffer protection for when errors explode into hundreds of lines. Either one alone is incomplete.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Check for timeout in the order &lt;code&gt;gtimeout → timeout → none&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;TIMEOUT_CMD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="nb"&gt;command&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; gtimeout &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nv"&gt;TIMEOUT_CMD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"gtimeout 60"&lt;/span&gt;
&lt;span class="k"&gt;elif &lt;/span&gt;&lt;span class="nb"&gt;command&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nb"&gt;timeout&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nv"&gt;TIMEOUT_CMD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"timeout 60"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;macOS has no &lt;code&gt;/usr/bin/timeout&lt;/code&gt;. Check with &lt;code&gt;command -v&lt;/code&gt; in this order before assigning, and the same script runs on Linux, macOS, and CI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Treat &lt;code&gt;rc=124&lt;/code&gt; separately from type errors&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The exit code when &lt;code&gt;timeout&lt;/code&gt;/&lt;code&gt;gtimeout&lt;/code&gt; kills the process is 124. This is the information "tsc didn't stop," which means something different from a type error. By printing a dedicated message and returning &lt;code&gt;exit 0&lt;/code&gt;, you can tell the agent about suspected concurrent launches while keeping the flow going.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;7. Pin &lt;code&gt;.tsbuildinfo&lt;/code&gt; to &lt;code&gt;node_modules/.cache/&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;TSBUILDINFO&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"node_modules/.cache/tsc-hook.tsbuildinfo"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Putting it in the project root increases &lt;code&gt;.gitignore&lt;/code&gt; maintenance. &lt;code&gt;node_modules/.cache/&lt;/code&gt; is already excluded in most projects, so no extra configuration is needed. Giving the file a distinctive name like &lt;code&gt;tsc-hook&lt;/code&gt; to separate its namespace from the app's own build cache is also important.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;8. Measure the 200KB guard threshold in your own project before setting it&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Check the current cache size with &lt;code&gt;stat -f %z&lt;/code&gt;, compare &lt;code&gt;time npx tsc&lt;/code&gt; against &lt;code&gt;time npx tsc --incremental&lt;/code&gt;, and find the inversion point yourself. Don't reuse the magic number; put your measured boundary value in place of &lt;code&gt;204800&lt;/code&gt; (200KB).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;9. Use &lt;code&gt;-f %z&lt;/code&gt; for macOS &lt;code&gt;stat&lt;/code&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Linux's &lt;code&gt;-c %s&lt;/code&gt; is invalid on macOS. Combined with &lt;code&gt;2&amp;gt;/dev/null || echo 0&lt;/code&gt;, you get the zero fallback for a missing file in one line. If you need both Linux and macOS support, &lt;code&gt;wc -c &amp;lt;&lt;/code&gt; also works, but it reads the whole file on large files, so &lt;code&gt;stat&lt;/code&gt; is slightly faster.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;10. Always verify with a manual run after installing&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; /path/to/your/project
bash ~/.claude/hooks/post_tsc_check.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it manually before going through Claude Code. Permissions, paths, &lt;code&gt;stat&lt;/code&gt; options, &lt;code&gt;tsconfig.json&lt;/code&gt; detection — this one command surfaces all of them. It's vastly faster than debugging through Claude Code. Once confirmed clean, enable it as the production hook.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;11. Temporarily disable the hook around big dependency changes&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Right after adding a new library to &lt;code&gt;package.json&lt;/code&gt;, &lt;code&gt;tsc&lt;/code&gt; runs a full analysis and the hook blocks for 30–60 seconds. In those moments the right call is to temporarily disable it with &lt;code&gt;chmod -x ~/.claude/hooks/post_tsc_check.sh&lt;/code&gt;, verify yourself with &lt;code&gt;npm install &amp;amp;&amp;amp; npx tsc --noEmit&lt;/code&gt;, and then restore &lt;code&gt;chmod +x&lt;/code&gt;. Rather than clinging to full automation, the flexibility to toggle the hook by situation is what keeps operations stable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;12. Keep the script within 61 lines&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The actual file is 61 lines including comments. Line count grows every time you add a feature, but if you hold the responsibility boundary of "the hook's job is only to check and report," it won't bloat. If processing gets complex, that's a sign to extract it into a separate dedicated script rather than the hook. "The clarity of 100 lines over 1,000" is achievable at this scale.&lt;/p&gt;




&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;What I built is a 61-line shell script. That alone changed the &lt;code&gt;closet-os&lt;/code&gt; hook execution time from "3.6x slower cold" to "1–3 seconds on diffs."&lt;/p&gt;

&lt;p&gt;The takeaways fit in three lines.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--incremental&lt;/code&gt; backfires on large projects&lt;/strong&gt;. The size of &lt;code&gt;.tsbuildinfo&lt;/code&gt; is the indicator, and on &lt;code&gt;closet-os&lt;/code&gt; the inversion point was around 200KB.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A one-line guard that automatically falls back to normal mode when the size from &lt;code&gt;stat -f %z&lt;/code&gt; exceeds &lt;code&gt;204800&lt;/code&gt; (200KB)&lt;/strong&gt; solves it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The hook is an "information channel," not a "control channel."&lt;/strong&gt; Always return &lt;code&gt;exit 0&lt;/code&gt;, and pass type errors to the agent via stdout.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;With this structure running, every time Claude Code introduces a type error, the "=== TypeScript型エラー検出 ===" feedback comes back and the agent autonomously runs the fix loop. The lag for a human to notice a type error is zero. It's one of the unglamorous parts supporting a ¥1.2M-a-month autonomous setup, but precisely because it's unglamorous, it has kept running stably for months.&lt;/p&gt;

&lt;p&gt;If you're feeling that &lt;code&gt;--incremental&lt;/code&gt; is heavy on a project around the size of &lt;code&gt;closet-os&lt;/code&gt; right now, start by running &lt;code&gt;ls -lh node_modules/.cache/tsc-hook.tsbuildinfo&lt;/code&gt;. If it's over 200KB, you can fix it today.&lt;/p&gt;

&lt;p&gt;Where does the inversion point land on your project — and what size does your &lt;code&gt;.tsbuildinfo&lt;/code&gt; hit before incremental stops paying off?&lt;/p&gt;




&lt;p&gt;The full picture of the setup, the breakdown of the ¥1.2M/month, and a 30-day walkthrough are in a paid note (Japanese)&lt;br&gt;
📕 &lt;a href="https://note.com/bokuwalily/n/n849b3a07784a" rel="noopener noreferrer"&gt;Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Written by **Lily&lt;/em&gt;* — I ship iOS apps and automate my content stack with Claude Code.&lt;br&gt;
Follow along: &lt;a href="https://bokuwalily.com" rel="noopener noreferrer"&gt;Portfolio&lt;/a&gt; · &lt;a href="https://x.com/bokuwalily" rel="noopener noreferrer"&gt;X&lt;/a&gt; · &lt;a href="https://github.com/bokuwalily" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;*&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>automation</category>
      <category>claudecode</category>
      <category>bash</category>
    </item>
  </channel>
</rss>
