<?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>5 Safety Guards for Auto-Archiving Another AI Agent's Conversations into My Vault: Designing a Manus API Bridge</title>
      <dc:creator>Lily</dc:creator>
      <pubDate>Sun, 27 Sep 2026 00:00:03 +0000</pubDate>
      <link>https://dev.to/bokuwalily/5-safety-guards-for-auto-archiving-another-ai-agents-conversations-into-my-vault-designing-a-35gd</link>
      <guid>https://dev.to/bokuwalily/5-safety-guards-for-auto-archiving-another-ai-agents-conversations-into-my-vault-designing-a-35gd</guid>
      <description>&lt;p&gt;My Claude Code conversations were already saved to my knowledge vault automatically, but anything I gave to Manus stayed inside its browser UI. I couldn't search those tasks or find them later. Now a small Python bridge checks the Manus API every 5 minutes and saves each task to the same shelf as my Claude Code logs. It uses five safety guards to keep it quiet, self-healing and free of secrets.&lt;/p&gt;

&lt;p&gt;Last time, I wrote about &lt;a href="https://zenn.dev/bokuwalily/articles/skills-cli-weekly-silent-fail" rel="noopener noreferrer"&gt;how a weekly batch job in my skills CLI had been failing silently&lt;/a&gt;. This post continues with the same vault setup. It covers how I &lt;strong&gt;automatically collect conversations from an AI agent other than Claude Code (Manus) through its API and store them as primary sources in a shared vault&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: Manus tasks weren't part of the primary sources
&lt;/h2&gt;

&lt;p&gt;The vault has a location called &lt;code&gt;raw/conversations/&lt;/code&gt;, and Claude Code conversation logs pile up there automatically. Tasks I sent to Manus were different. The only way to see their contents was to open the task screen in the browser. When I later tried to remember what I had asked for in a task, it didn't show up in vault search or in the shared hot cache.&lt;/p&gt;

&lt;p&gt;Manus has a public API that lets you read your own task list and messages. That was the starting point for this bridge: fetch those tasks on a schedule and put them on the same shelf (the raw layer) as the Claude Code conversation logs. &lt;code&gt;launchd&lt;/code&gt; runs &lt;code&gt;~/Documents/claude-obsidian/bin/manus-task-archive.py&lt;/code&gt; every 5 minutes, and the script writes its output to &lt;code&gt;~/Documents/my-knowledge-base/raw/manus-conversations/&lt;/code&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note&lt;/strong&gt;&lt;br&gt;
This script &lt;strong&gt;only fetches and saves&lt;/strong&gt;. A separate, safe reflection worker handles summaries and "proposals to the vault." This script never does that work. A core rule of how I run the vault is to keep raw primary sources separate from the layer that interprets them.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Design: credentials live only in the Keychain
&lt;/h2&gt;

&lt;p&gt;I don't want the API key in the code or in any file, so the script reads it from the macOS Keychain on every run.&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;KEYCHAIN_SERVICE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;manus-vault-memory-archiver&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;keychain_key&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="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="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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;find-generic-password&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;-s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;KEYCHAIN_SERVICE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-w&lt;/span&gt;&lt;span class="sh"&gt;"&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="bp"&gt;True&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="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="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;or&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="n"&gt;stdout&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;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Manus archive API key is unavailable in macOS Keychain&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="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stdout&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The docstring also states that this worker never passes the key to &lt;code&gt;print&lt;/code&gt; or &lt;code&gt;write&lt;/code&gt;. If the key can't be fetched, the function raises an exception. The &lt;code&gt;except&lt;/code&gt; in &lt;code&gt;main()&lt;/code&gt; logs it and the run stops. A broken key doesn't affect other jobs or conversations.&lt;/p&gt;

&lt;h2&gt;
  
  
  Incremental sync: only process tasks whose revision changed
&lt;/h2&gt;

&lt;p&gt;The script fetches Manus tasks by paging through &lt;code&gt;task.list&lt;/code&gt; → &lt;code&gt;task.listMessages&lt;/code&gt;. Fetching every message on every run would be wasteful. Instead, the script saves each task's update time as its "revision" in a state file and skips the task if that value hasn't changed.&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;task_revision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&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="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Any&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="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="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;updated_at&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;updatedAt&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;modified_at&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;timestamp&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;created_at&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;createdAt&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;task&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="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="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="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;return&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;task&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="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unknown&lt;/span&gt;&lt;span class="sh"&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 python"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;accessible&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;ident&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;task_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&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;ident&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;revision&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;task_revision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&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;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;tasks&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="n"&gt;ident&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;revision&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;messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list_messages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;credential&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ident&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;archive_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;messages&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tasks&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="n"&gt;ident&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;revision&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is the actual state file (&lt;code&gt;AI/.runtime/manus-task-archive-state.json&lt;/code&gt;):&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="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tasks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&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;"rRkPkzEkUrImaYRXsI3sLY"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1790174319"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"7hBaAc4dhs9hycZaAnC8z5"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1789628528"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"5VXpxPKZkzify3yp9FA5LX"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1790077323"&lt;/span&gt;&lt;span class="w"&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;"accessible_task_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="w"&gt;
&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;p&gt;The launchd log (&lt;code&gt;~/.claude/logs/manus-task-archive.log&lt;/code&gt;) shows that even though the job runs every 5 minutes, most runs find nothing new:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[2026-09-26 07:43:32 +0900] ok: accessible=3 updated=0
[2026-09-26 07:48:33 +0900] ok: accessible=3 updated=0
[2026-09-26 07:53:34 +0900] ok: accessible=3 updated=0
[2026-09-26 07:58:51 +0900] ok: accessible=3 updated=0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A run of &lt;code&gt;accessible=3 updated=0&lt;/code&gt; lines means the API returns 3 tasks each time, but none of their revisions changed, so nothing gets written. Of all my measurements, this was the clearest proof that the diff check works correctly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Self-healing: stale locks, and leaving nothing behind after a crash
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;launchd&lt;/code&gt; &lt;code&gt;StartInterval&lt;/code&gt; is fixed at 300 seconds (5 minutes), set in &lt;code&gt;~/Library/LaunchAgents/com.shun.manus-vault-archive.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;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;ThrottleInterval&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;60&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;12&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;Overlapping runs on a 5-minute schedule would cause trouble, so the script uses a lock directory created with &lt;code&gt;mkdir&lt;/code&gt; to prevent more than one run at a time. The case I paid attention to was a previous run crashing and leaving its lock behind.&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;acquire_lock&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;STATE_DIR&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mkdir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;parents&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;exist_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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;LOCK_DIR&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists&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;age&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="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;LOCK_DIR&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stat&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;st_mtime&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;age&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1800&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;LOCK_DIR&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rmdir&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="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;pass&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;LOCK_DIR&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mkdir&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;except&lt;/span&gt; &lt;span class="nb"&gt;FileExistsError&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a lock is older than 30 minutes (1800 seconds, or six 5-minute cycles), the next run removes it. The system recovers on a later cycle before a human notices and runs &lt;code&gt;rm&lt;/code&gt;. Releasing the lock also sits in a &lt;code&gt;finally&lt;/code&gt; block, so the lock is always removed, whether the run crashes with an exception or exits normally.&lt;/p&gt;

&lt;p&gt;The log shows this working in practice:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[2026-09-26 06:42:45 +0900] ok: accessible=3 updated=0
[2026-09-26 06:48:15 +0900] error: RuntimeError: API request failed: &amp;lt;urlopen error [Errno 8] nodename nor servname provided, or not known&amp;gt;
[2026-09-26 06:53:16 +0900] ok: accessible=3 updated=0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At 06:48, DNS resolution failed briefly and the run crashed with an exception. The &lt;code&gt;finally&lt;/code&gt; block had already released the lock, so the next run 5 minutes later (06:53) finished normally with nothing blocking it. The lock design was meant to do exactly this: fix itself before a human notices.&lt;/p&gt;

&lt;h2&gt;
  
  
  Redacting secrets and meeting links
&lt;/h2&gt;

&lt;p&gt;This raw layer becomes "primary source material for future AI agents to read." To keep secrets out of it, the script scrubs them with regular expressions.&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;SECRET_PATTERNS&lt;/span&gt; &lt;span class="o"&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;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;\bsk-[A-Za-z0-9_\-]{16,}\b&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;[REDACTED_API_KEY]&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;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;(?i)\b(?:api[_ -]?key|token|password|secret|bearer)\s*[:=]\s*[^\s`&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;\"]{8,}&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;[REDACTED_SECRET]&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;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;https?://(?:meet\.google\.com|zoom\.us/j|teams\.microsoft\.com)/[^\s)\]&amp;gt;]+&lt;/span&gt;&lt;span class="sh"&gt;"&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="n"&gt;I&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[REDACTED_MEETING_LINK]&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The comment in the code explains the design intent:&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;# Deliberately conservative. The archive is a private raw source, but it must
# not become an accidental secret store shared with future agents.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part is the order of reasoning. I didn't start from "this raw store is personal, so it can be a little loose." I started from "future agents will read it, so keep it conservative." Zoom, Meet and Teams meeting URLs get the same treatment as API key patterns. Conversation logs often contain lines like "join from this link," and I don't want those saved permanently in the primary sources.&lt;/p&gt;

&lt;h2&gt;
  
  
  Backdating mtime: keeping the index timeline intact
&lt;/h2&gt;

&lt;p&gt;Each archived Markdown file's modification time (mtime) is set to &lt;strong&gt;the time the task was last updated in Manus, not the time it was archived&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;output&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;RAW_DIR&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;manus_&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ident&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;.md&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="nf"&gt;atomic_write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&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;parts&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;rstrip&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="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;stamp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;updated&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;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;utime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stamp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stamp&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without this, a task that finished 9 days ago but was archived for the first time today would appear as "something that just happened" in the shared hot cache and other indexes that sort by mtime. In fact, one of the files from the first archive run had an update date of 9/17:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# 会話ログ: Manus / Agent Manus / 7hBaAc4d&lt;/span&gt;
日時: 2026-09-17 07:02 UTC
更新: 2026-09-17 07:02 UTC
Task ID: &lt;span class="sb"&gt;`7hBaAc4dhs9hycZaAnC8z5`&lt;/span&gt;
Status: &lt;span class="sb"&gt;`waiting`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The file was written today, but its mtime stays at 9/17, so it doesn't appear in the list of "today's events." Writes also go through &lt;code&gt;atomic_write&lt;/code&gt;, which writes a temp file and then calls &lt;code&gt;os.replace&lt;/code&gt;. If the process dies partway through, no half-written file is left behind.&lt;/p&gt;

&lt;p&gt;After writing, if at least one task was updated, the script also runs the shared hot-cache update script:&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="n"&gt;updated_files&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;UPDATE_HOT&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists&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="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;executable&lt;/span&gt;&lt;span class="p"&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;UPDATE_HOT&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;stdout&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="n"&gt;DEVNULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stderr&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="n"&gt;DEVNULL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This way, Manus tasks reach the vault's "recent sessions" list through the same path as the other raw conversation logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 5 safety guards at a glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Guard&lt;/th&gt;
&lt;th&gt;What it protects against&lt;/th&gt;
&lt;th&gt;Implementation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Keychain-only credential&lt;/td&gt;
&lt;td&gt;Key leaks&lt;/td&gt;
&lt;td&gt;Call &lt;code&gt;security find-generic-password&lt;/code&gt; on every run; never write the key in code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Incremental sync by revision diff&lt;/td&gt;
&lt;td&gt;Wasted API calls and duplicate writes&lt;/td&gt;
&lt;td&gt;Compare &lt;code&gt;state["tasks"][ident]&lt;/code&gt; with the revision&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stale-lock self-healing&lt;/td&gt;
&lt;td&gt;Permanent skips after a crash&lt;/td&gt;
&lt;td&gt;Automatically &lt;code&gt;rmdir&lt;/code&gt; a lock dir older than 1800 seconds on the next run&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Secret / meeting-link redaction&lt;/td&gt;
&lt;td&gt;Secrets leaking into primary sources&lt;/td&gt;
&lt;td&gt;Replace with 3 regex patterns&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;mtime backdating&lt;/td&gt;
&lt;td&gt;Breaking the index timeline&lt;/td&gt;
&lt;td&gt;Pin the file to the task's update time with &lt;code&gt;os.utime&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Key names for task IDs and revisions vary across API responses&lt;/strong&gt; → I made the implementation defensive: &lt;code&gt;task.get("task_id") or task.get("id")&lt;/code&gt;, and for revisions, check keys in order from &lt;code&gt;updated_at&lt;/code&gt; through &lt;code&gt;createdAt&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Message paging might never end&lt;/strong&gt; → I added a safety valve that raises &lt;code&gt;RuntimeError&lt;/code&gt; once paging goes past &lt;code&gt;MAX_MESSAGE_PAGES&lt;/code&gt; (100). I chose "fail with an error I'll notice" over an infinite loop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Brief network outages surface as exceptions&lt;/strong&gt; → The log shows a real &lt;code&gt;urlopen error&lt;/code&gt; failure (2026-09-26 06:48). Because lock release is in &lt;code&gt;finally&lt;/code&gt;, the next 5-minute cycle recovered normally with no special handling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Leaving mtime as "now" breaks time-ordered indexes&lt;/strong&gt; → Until I backdated files to the task's update time, old tasks kept showing up as "today's events."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Loose redaction would cause problems once future agents read the archive&lt;/strong&gt; → As the code comment says, I made the patterns conservative, assuming this private raw source will be read by other agents in the future.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;The goal is to put Manus tasks on the same &lt;code&gt;raw/&lt;/code&gt; shelf as Claude Code conversation logs, &lt;strong&gt;as primary sources&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Credentials are read from the Keychain on every run and never stored in code or logs.&lt;/li&gt;
&lt;li&gt;Changes are detected &lt;strong&gt;only by comparing revision strings&lt;/strong&gt;. &lt;code&gt;accessible=3 updated=0&lt;/code&gt; is the normal, most common result.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;stale lock is removed automatically with &lt;code&gt;rmdir&lt;/code&gt;&lt;/strong&gt;, so duplicate runs and post-crash lockups get fixed without waiting for a human.&lt;/li&gt;
&lt;li&gt;Secrets and meeting links are redacted with regular expressions, and the patterns are kept conservative &lt;strong&gt;because future agents will read the archive&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Each file's mtime is set to &lt;strong&gt;the time of the original event&lt;/strong&gt;, not the fetch time, so the vault's timeline indexes stay intact.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next time, I plan to write about how the reflection worker safely summarizes the Manus conversations in this raw layer and promotes them into the wiki.&lt;/p&gt;

&lt;p&gt;If you archive conversations from more than one AI agent, how do you keep secrets and outdated timestamps out of the shared record?&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>python</category>
      <category>automation</category>
      <category>llm</category>
    </item>
    <item>
      <title>3 Weekly Runs, 3 Failures: My "Fixed" Claude Code Skill Auto-Update Failed Silently for 3 Weeks</title>
      <dc:creator>Lily</dc:creator>
      <pubDate>Sat, 26 Sep 2026 00:00:08 +0000</pubDate>
      <link>https://dev.to/bokuwalily/3-weekly-runs-3-failures-my-fixed-claude-code-skill-auto-update-failed-silently-for-3-weeks-18fd</link>
      <guid>https://dev.to/bokuwalily/3-weekly-runs-3-failures-my-fixed-claude-code-skill-auto-update-failed-silently-for-3-weeks-18fd</guid>
      <description>&lt;p&gt;The fix had comments. It had logs. It even had an audit note saying "measured 4m00s, exit 0." It still failed every Sunday for three weeks straight, and nobody noticed.&lt;/p&gt;

&lt;p&gt;Last time, I wrote about &lt;a href="https://dev.to/bokuwalily/6-pitfalls-syncing-ai-memory-to-google-drive-with-no-api-keeping-file-ids-intact-4a19"&gt;handling external drive mounts and file identity&lt;/a&gt;. This one is less flashy but harder to deal with. It's about &lt;strong&gt;an automation I thought I had fixed that kept failing silently for three weeks, while the comments and logs saying it was fixed stayed in place&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The job itself is boring. It's &lt;code&gt;npx skills@latest update -g&lt;/code&gt;, the CLI that updates Claude Code skills, run weekly by launchd. I had stacked three layers on top of it: a fix for unauthenticated GitHub API rate limits, a guard against hangs, and failure detection. After that I assumed it was fixed. Then I opened the actual logs, and the last three Sunday runs had all failed for the same reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: &lt;code&gt;npx skills@latest update -g&lt;/code&gt; is fragile against the unauthenticated GitHub API
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;~/Library/LaunchAgents/com.lily.skills-update.plist&lt;/code&gt; is a plist whose only job is to launch the skill update script every Sunday at 4: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;Label&lt;span class="nt"&gt;&amp;lt;/key&amp;gt;&amp;lt;string&amp;gt;&lt;/span&gt;com.lily.skills-update&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;&amp;lt;array&amp;gt;&amp;lt;string&amp;gt;&lt;/span&gt;/bin/zsh&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&amp;lt;string&amp;gt;&lt;/span&gt;-lc&lt;span class="nt"&gt;&amp;lt;/string&amp;gt;&amp;lt;string&amp;gt;&lt;/span&gt;~/.claude/scripts/skills-auto-update.sh&lt;span class="nt"&gt;&amp;lt;/string&amp;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;&amp;lt;dict&amp;gt;&amp;lt;key&amp;gt;&lt;/span&gt;Weekday&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;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;4&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;40&lt;span class="nt"&gt;&amp;lt;/integer&amp;gt;&amp;lt;/dict&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;skills-auto-update.sh&lt;/code&gt; it calls basically just runs &lt;code&gt;npx skills@latest update -g --yes&lt;/code&gt;. The catch is that this CLI &lt;strong&gt;routinely hits GitHub's API rate limit when unauthenticated&lt;/strong&gt;, and depending on network conditions, its fetch can hang. The more skills you have, the more often this happens. Leave it alone and one day you notice nothing has updated in months.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design: three layers of self-healing
&lt;/h2&gt;

&lt;p&gt;To handle this, the script has three defenses. Here are the relevant parts of the actual file.&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;# skills CLI は未認証で GitHub API を叩くため、スキル数が増えるとレート上限に当たる。&lt;/span&gt;
&lt;span class="c"&gt;# gh は認証済み(5000/h)なのでトークンを渡す。取れなくても従来どおり動く。&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; gh &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;GH_TOKEN&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;gh auth token 2&amp;gt;/dev/null&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="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GH_TOKEN&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;export &lt;/span&gt;GH_TOKEN &lt;span class="nv"&gt;GITHUB_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$GH_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;fi
fi&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;# timeout 上限: npx の network fetch がハングするのを gtimeout で頭打ち（async hook なので非ブロッキング）。&lt;/span&gt;
  &lt;span class="c"&gt;# 45s だった頃はスキル数が増えて毎回タイムアウトし、5,833行の失敗ログを吐きながら&lt;/span&gt;
  &lt;span class="c"&gt;# 一度も更新できていなかった（2026-08-08 監査で実測4分00秒・exit 0 を確認）。&lt;/span&gt;
  &lt;span class="c"&gt;# STAMP は成功時のみ更新する。旧実装は失敗でも touch していたため、&lt;/span&gt;
  &lt;span class="c"&gt;# ネットワーク不調時に「更新が静かに失敗し続ける」状態になっていた（2026-06-11 監査）。&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
    if &lt;/span&gt;gtimeout 600 &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$NPXBIN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; skills@latest update &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nt"&gt;--yes&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ERRLOG&lt;/span&gt;&lt;span class="s2"&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;$STAMP&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;"[&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;'+%Y-%m-%d %H:%M:%S'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;] skills update failed (timeout or error) — STAMP not updated, will retry next session"&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;$ERRLOG&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 three layers work like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pass &lt;code&gt;gh auth token&lt;/code&gt;&lt;/strong&gt;: moves from unauthenticated (60/h) to authenticated (5000/h) to avoid the rate limit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;gtimeout 600&lt;/code&gt;&lt;/strong&gt;: 45 seconds had already proven too short, and runs stalled more as the skill count grew, so the job is capped at 10 minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Update STAMP only on success&lt;/strong&gt;: the old implementation ran &lt;code&gt;touch&lt;/code&gt; even on failure, so "last run" kept advancing when runs failed and problems stayed invisible. To fix this, &lt;code&gt;STAMP&lt;/code&gt; is now updated only on success, and failures are written to &lt;code&gt;skills-update.err&lt;/code&gt; with a reason.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;According to the comments, the 2026-08-08 audit confirmed "measured 4m00s, exit 0," so this design was verified to work at least once.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note&lt;/strong&gt;&lt;br&gt;
It's good design that the code comments record &lt;em&gt;why&lt;/em&gt; each fix was made (the failed 45-second timeout, the 5,833-line log, the 2026-08-08 measurement). But those are only &lt;strong&gt;facts verified at that point in time&lt;/strong&gt;. They don't prove the job keeps working afterward. Forget that difference and you'll read the comments and assume it's fixed.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Reading the real logs: "timeout or error" three weeks in a row
&lt;/h2&gt;

&lt;p&gt;That's the design intent. So what actually happened? Here is &lt;code&gt;~/.claude/logs/skills-update.err&lt;/code&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="go"&gt;[2026-09-06 04:40:38] skills update failed (timeout or error) — STAMP not updated, will retry next session
npm warn exec The following package was not found and will be installed: skills@1.5.26
[2026-09-13 05:27:06] skills update failed (timeout or error) — STAMP not updated, will retry next session
npm warn exec The following package was not found and will be installed: skills@1.7.0
[2026-09-20 04:41:35] skills update failed (timeout or error) — STAMP not updated, will retry next session
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;9/6, 9/13, 9/20: all Sundays, all around the plist's start time (about 4:40). So for these three weeks, &lt;strong&gt;the Sunday job started every time, failed every time, and logged the same reason every time&lt;/strong&gt;. The &lt;code&gt;npm warn exec&lt;/code&gt; lines in between show that &lt;code&gt;npx&lt;/code&gt;'s own bootstrap (fetching &lt;code&gt;skills@1.5.26&lt;/code&gt; → &lt;code&gt;skills@1.7.0&lt;/code&gt;) was getting through. So the failure isn't before &lt;code&gt;npx&lt;/code&gt; launches. It's &lt;strong&gt;further in, in &lt;code&gt;skills update&lt;/code&gt; itself&lt;/strong&gt;, exactly where you'd suspect rate limiting or a network hang.&lt;/p&gt;

&lt;h2&gt;
  
  
  A contradiction in the STAMP file
&lt;/h2&gt;

&lt;p&gt;Now another real file: &lt;code&gt;~/.claude/skills-last-update&lt;/code&gt;, which should only be updated on success.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;birth: Aug  8 11:54:13 2026 / mtime: Sep 10 17:02:10 2026
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It was created on 2026-08-08, the same date as the audit behind the "measured 4m00s, exit 0" design comment. That part adds up.&lt;/p&gt;

&lt;p&gt;The mtime is the problem: &lt;strong&gt;2026-09-10 17:02&lt;/strong&gt;. That doesn't match any slot in the plist's schedule (every Sunday at 4:40). It's not a Sunday, and it's not the morning. So the run that last updated this STAMP most likely &lt;strong&gt;did not come from launchd's weekly automated run&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Every automated run recorded after that (9/6, 9/13, 9/20) failed. So for at least these three weeks, &lt;strong&gt;the automated path alone never succeeded once&lt;/strong&gt;. The STAMP had stopped moving three weeks earlier, and nobody went to look.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the "fix" didn't fix anything
&lt;/h2&gt;

&lt;p&gt;This is the part I most wanted to write about. Each of the three self-healing layers is working as intended.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;gtimeout 600&lt;/code&gt; works: the exit code came back within 600 seconds and the script fell into the &lt;code&gt;else&lt;/code&gt; branch. If it were truly hanging, the timing of the &lt;code&gt;[timeout]&lt;/code&gt; entries would be more irregular, but all three were logged at regular points within 1 to 47 minutes of launch.&lt;/li&gt;
&lt;li&gt;Success-only STAMP works: nothing succeeded, so the STAMP didn't advance. That's the correct, designed behavior.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;echo ... &amp;gt;&amp;gt; "$ERRLOG"&lt;/code&gt; works: three weeks of entries are all there.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;So none of the safety mechanisms is broken.&lt;/strong&gt; Yet updates failed three weeks running. What the design changed was going from "a failure tells you nothing" to "you can see when a failure happened and why." &lt;strong&gt;It never fixed the failure itself.&lt;/strong&gt; All the "should work" code really did was turn a silent failure into a logged one. The root cause was untouched.&lt;/p&gt;

&lt;p&gt;The most suspicious variable left is &lt;code&gt;gh auth token&lt;/code&gt;. The script comment says "it still works as before even if the token can't be obtained." Read the other way, that means &lt;strong&gt;nobody checks whether the token was obtained&lt;/strong&gt;. Running &lt;code&gt;gh auth status&lt;/code&gt; locally, I confirmed that besides the authenticated account, there is an account whose keyring access times out. &lt;code&gt;gh auth token&lt;/code&gt; itself currently exits normally (exit 0), but nothing guarantees it resolves the same way every time in launchd's non-interactive, 4:40 a.m. context. If keychain access stalls there and &lt;code&gt;GH_TOKEN&lt;/code&gt; is passed empty, the script gives no warning, &lt;strong&gt;falls back to the unauthenticated path, and hits the same rate limit it was meant to avoid&lt;/strong&gt;. These logs alone can't prove that, so I'm leaving it as the next hypothesis to dig into.&lt;/p&gt;

&lt;h2&gt;
  
  
  How this ties to the macos-hook-path-timeout skill
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;gtimeout&lt;/code&gt; dependency has some history. &lt;code&gt;~/.claude/skills/auto/macos-hook-path-timeout/SKILL.md&lt;/code&gt; is an existing skill that was auto-generated based on this very script.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;### 症状2: スクリプトの `timeout 60 ...` が効かない（`timeout: command not found`）&lt;/span&gt;
macOSは GNU coreutils の &lt;span class="sb"&gt;`timeout`&lt;/span&gt; を&lt;span class="gs"&gt;**標準搭載しない**&lt;/span&gt;。ECCのhooks.md等が前提にしていても不発。

&lt;span class="gs"&gt;**修正**&lt;/span&gt;: &lt;span class="sb"&gt;`brew install coreutils`&lt;/span&gt; で &lt;span class="sb"&gt;`gtimeout`&lt;/span&gt; が入る。スクリプトは
&lt;span class="sb"&gt;`command -v gtimeout &amp;gt;/dev/null &amp;amp;&amp;amp; TIMEOUT_CMD="gtimeout 60"`&lt;/span&gt; の形で優先検出させる
（&lt;span class="sb"&gt;`post_tsc_check.sh`&lt;/span&gt; / &lt;span class="sb"&gt;`skills-auto-update.sh`&lt;/span&gt; はこの形。coreutils導入だけで設計通りに起動する）。
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Translation: &lt;em&gt;Symptom 2: a script's &lt;code&gt;timeout 60 ...&lt;/code&gt; doesn't work (&lt;code&gt;timeout: command not found&lt;/code&gt;). macOS does not ship GNU coreutils' &lt;code&gt;timeout&lt;/code&gt;, so it fails even when docs like ECC's hooks.md assume it exists. Fix: &lt;code&gt;brew install coreutils&lt;/code&gt; provides &lt;code&gt;gtimeout&lt;/code&gt;. Scripts should detect it first with &lt;code&gt;command -v gtimeout &amp;gt;/dev/null &amp;amp;&amp;amp; TIMEOUT_CMD="gtimeout 60"&lt;/code&gt;. &lt;code&gt;post_tsc_check.sh&lt;/code&gt; and &lt;code&gt;skills-auto-update.sh&lt;/code&gt; use this form, so installing coreutils alone makes them start as designed.&lt;/em&gt;)&lt;/p&gt;

&lt;p&gt;This skill's &lt;code&gt;Verification&lt;/code&gt; section only goes as far as checking that the &lt;code&gt;gtimeout&lt;/code&gt; command &lt;strong&gt;exists&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;command&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; gtimeout &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;"post_tsc_check 等の timeout 分岐が起動する"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So all this skill guarantees is that "if &lt;code&gt;gtimeout&lt;/code&gt; is installed, the timeout branch runs." Whether the job actually succeeds once it's in that branch is out of scope. The three-week failure streak happened in exactly that gap: &lt;code&gt;gtimeout&lt;/code&gt; started correctly, capped correctly, and logged correctly, and &lt;code&gt;skills update&lt;/code&gt; itself still didn't get through.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Raising the 45-second timeout to 600 seconds "should have" fixed it, but it didn't fix the network or rate-limit problem itself&lt;/strong&gt; → A longer timeout only helps with hangs. If the cause is something else (rate limiting, or GH_TOKEN not being obtained), it will fail in the same place however far you extend it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Success-only STAMP didn't fix failures. It only made them visible&lt;/strong&gt; → Unless something regularly reads the logs (cron monitoring, a statusline integration), nobody reads the visible failures and you're back to "nothing has updated in months."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Whether &lt;code&gt;npm warn exec&lt;/code&gt; shows up tells you where the failure is&lt;/strong&gt; → If that line is there, &lt;code&gt;npx&lt;/code&gt;'s bootstrap succeeded, so the cause is inside &lt;code&gt;skills update&lt;/code&gt; (network/API side).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"It still works as before even if the token can't be obtained" is the weakest fallback&lt;/strong&gt; → An empty &lt;code&gt;GH_TOKEN&lt;/code&gt; quietly drops to the unauthenticated path. If you don't log whether auth succeeded, you can't tell when a supposed fix has quietly stopped doing anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compare the STAMP mtime against the cron schedule&lt;/strong&gt; → If the update time doesn't match the job's start time, the success probably didn't come from the automated path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An existing auto-skill's Verification section may only check that a command exists&lt;/strong&gt; → "Does the command exist?" and "Does the job keep succeeding afterward?" are separate things to verify.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;All three self-healing layers (GH_TOKEN injection, gtimeout, success-only STAMP) behaved as designed, both in the code and in the run logs.&lt;/li&gt;
&lt;li&gt;Even so, the last three weekly runs (9/6, 9/13, 9/20) all failed for the same reason, and the STAMP stayed stuck at 2026-09-10.&lt;/li&gt;
&lt;li&gt;The safety mechanisms only turned a "silent failure" into a "logged failure." &lt;strong&gt;They did not fix the root cause.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;"Should work" (design, past measurements) and "actually worked" (raw logs, STAMP mtime) are different things, and you need to check them against each other regularly.&lt;/li&gt;
&lt;li&gt;Whether &lt;code&gt;gh auth token&lt;/code&gt; resolves every time in launchd's non-interactive context is still unverified. That's the next hypothesis to rule out.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next time, I plan to check whether &lt;code&gt;gh auth token&lt;/code&gt; really resolves every time in launchd's context (non-interactive, early morning, keychain not unlocked) and report whether that turned out to be the root cause.&lt;/p&gt;

&lt;p&gt;What about you: how do you check that your "fixed" background jobs are still succeeding, and not just logging their failures neatly?&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>macos</category>
    </item>
    <item>
      <title>6 Pitfalls Syncing AI Memory to Google Drive With No API: Keeping File IDs Intact</title>
      <dc:creator>Lily</dc:creator>
      <pubDate>Fri, 25 Sep 2026 00:00:04 +0000</pubDate>
      <link>https://dev.to/bokuwalily/6-pitfalls-syncing-ai-memory-to-google-drive-with-no-api-keeping-file-ids-intact-4a19</link>
      <guid>https://dev.to/bokuwalily/6-pitfalls-syncing-ai-memory-to-google-drive-with-no-api-keeping-file-ids-intact-4a19</guid>
      <description>&lt;p&gt;Use the obvious "write a temp file, then &lt;code&gt;os.replace()&lt;/code&gt;" pattern on a Google Drive for Desktop mount and the file keeps its name but loses its Drive identity. Drive sees the original file disappear and a brand-new one show up in its place, with a new file id. I switched to overwriting the same file descriptor in place. The file id now survives every push, and the sync path still needs &lt;strong&gt;no gws auth and no network calls&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Last time I wrote about &lt;a href="https://dev.to/bokuwalily/i-dont-trust-the-llm-that-writes-my-shared-memory-3-regex-gates-before-claude-code-auto-commits-3i77"&gt;isolating untrusted external output&lt;/a&gt;. This post covers the sync path behind it: &lt;code&gt;memory-drive-mount-sync.py&lt;/code&gt;, which treats the local Google Drive for Desktop mount folder as &lt;strong&gt;nothing more than a place to drop files&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: add a sync path without adding another API
&lt;/h2&gt;

&lt;p&gt;I already have one path that puts vault memory in the cloud: &lt;code&gt;memory-cloud-sync.py&lt;/code&gt;, built on &lt;code&gt;gws&lt;/code&gt; (Google Workspace CLI). It's thorough: OAuth, the Drive API, Docs API &lt;code&gt;batchUpdate&lt;/code&gt;, and CAS-style conflict detection via &lt;code&gt;requiredRevisionId&lt;/code&gt;. The cost of that thoroughness is a heavy set of dependencies.&lt;/p&gt;

&lt;p&gt;My Mac also already runs &lt;strong&gt;Google Drive for Desktop&lt;/strong&gt;, which mounts Drive contents as local files under &lt;code&gt;~/Library/CloudStorage/GoogleDrive-&amp;lt;google-account&amp;gt;/マイドライブ/&lt;/code&gt; (My Drive). Writing there should sync without any extra auth or API calls.&lt;/p&gt;

&lt;p&gt;But the naive "write to a temp file and rename with &lt;code&gt;os.replace()&lt;/code&gt;" approach breaks things. &lt;code&gt;os.replace()&lt;/code&gt; swaps the inode, and Drive for Desktop ties each Drive file id to the local file object (the inode). Swap that object out with a rename and Drive sees the original file vanish and a new one appear. &lt;strong&gt;The file id the file carried is lost.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Design: push overwrites the same file, pull reads only a narrow set of notes
&lt;/h2&gt;

&lt;p&gt;The module docstring of &lt;code&gt;memory-drive-mount-sync.py&lt;/code&gt; lays out exactly what the script is allowed to do:&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="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;One bounded, single-run local sync against a Google Drive for Desktop mount.

This is deliberately separate from memory-cloud-sync.py: it never calls ``gws``,
never touches network credentials, and does nothing beyond one push (of the
locally built curated snapshot) and one bounded pull (of a handful of plain
dated notes already sitting under the mounted folder). It only ever reads and
writes: the vault&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s own AI/.runtime build cache, a single named file inside
the mount directory (in place), and AI/INBOX/cloud inside the vault.
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It has exactly three jobs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;push&lt;/strong&gt;: take the &lt;code&gt;SHARED-MEMORY.txt&lt;/code&gt; built from the vault by &lt;code&gt;memory-bridge.py build&lt;/code&gt; and overwrite the file of the same name in the mount, &lt;strong&gt;as the same file&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;pull&lt;/strong&gt;: pick up only date-named plain-text notes in the mount (&lt;code&gt;.txt&lt;/code&gt;/&lt;code&gt;.md&lt;/code&gt;/&lt;code&gt;.markdown&lt;/code&gt;, max 50 files, 256KB per file) and stage them in quarantine under the vault's &lt;code&gt;AI/INBOX/cloud&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;block concurrent runs with a single-run lock&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; With no gws and no Drive API, there's no competing-write detection like &lt;code&gt;requiredRevisionId&lt;/code&gt;. Instead, TOCTOU (check-then-use) gaps are closed with &lt;strong&gt;POSIX fstat/inode matching, O_NOFOLLOW, and flock&lt;/strong&gt;. With no auth layer, the safety comes from being meticulous at the filesystem layer.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Push: overwriting without breaking the file id
&lt;/h2&gt;

&lt;p&gt;This is the core function. Instead of &lt;code&gt;os.replace()&lt;/code&gt;, it does &lt;code&gt;seek(0)&lt;/code&gt; → &lt;code&gt;write&lt;/code&gt; → &lt;code&gt;truncate&lt;/code&gt; on the existing file descriptor.&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;overwrite_shared_memory&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;Path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;bytes&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;Overwrite an existing regular file in place, preserving its identity.

    Deliberately never os.replace(): the mount file&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s identity (e.g. a Google
    Drive file id behind the local inode) must be preserved rather than
    swapped for a new one.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;initial&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;check_regular_non_symlink&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;flags&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;O_RDWR&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;hasattr&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;O_NOFOLLOW&lt;/span&gt;&lt;span class="sh"&gt;"&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;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;O_NOFOLLOW&lt;/span&gt;
    &lt;span class="n"&gt;fd&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="nf"&gt;open&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;flags&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;opened&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="nf"&gt;fstat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fd&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;stat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;S_ISREG&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;opened&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;st_mode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;opened&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;st_dev&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;opened&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;st_ino&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="n"&gt;initial&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;st_dev&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;initial&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;st_ino&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;MountSyncError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mount_target_changed_during_write&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;handle&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="nf"&gt;fdopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;r+b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;closefd&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;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;seek&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;handle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;truncate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flush&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="nf"&gt;fsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fileno&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;finally&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="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fd&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;check_regular_non_symlink&lt;/code&gt; first runs an &lt;code&gt;lstat&lt;/code&gt;-based safety check (not a symlink, is a regular file). Then the file is &lt;code&gt;open()&lt;/code&gt;ed, and the &lt;code&gt;(st_dev, st_ino)&lt;/code&gt; from an immediate &lt;code&gt;fstat&lt;/code&gt; is &lt;strong&gt;compared with the &lt;code&gt;lstat&lt;/code&gt; result taken before the open&lt;/strong&gt;. If they differ, the file was swapped between the check and the open (TOCTOU), and the write is aborted.&lt;/p&gt;

&lt;p&gt;The push itself also has a guard before writing:&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;mount_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;mount_dir&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SHARED-MEMORY.txt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="c1"&gt;# Missing / symlink / non-regular mount target: refuse to write, surface a
# non-sensitive error code, and never fall back to creating a new file
# (that would mint a new Drive file id in place of the shared one).
&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;check_regular_non_symlink&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mount_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the target is missing, is a symlink, or isn't a regular file, the script &lt;strong&gt;does not fall back to creating a new file&lt;/strong&gt;. Creating one would make Drive issue a different file id. Content is compared with the &lt;code&gt;updated:&lt;/code&gt; line stripped out, and if nothing else changed, the write is skipped entirely. That way a timestamp-only difference doesn't set off a Drive sync event on every run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pull: only date-named plain-text notes
&lt;/h2&gt;

&lt;p&gt;The pull targets are explicitly narrowed:&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;MAX_PULL_FILES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;
&lt;span class="n"&gt;MAX_PULL_FILE_BYTES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;
&lt;span class="n"&gt;NAME_DATE_RE&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;^\d{4}-\d{2}-\d{2}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;PULL_EXTENSIONS&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;.txt&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;text/plain&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;.md&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;text/markdown&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;.markdown&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;text/markdown&lt;/span&gt;&lt;span class="sh"&gt;"&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;classify_pull_candidate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&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="nb"&gt;str&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;NAME_DATE_RE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&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;None&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upper&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;SHARED-MEMORY&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;None&lt;/span&gt;
    &lt;span class="n"&gt;lower&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;lower&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;suffix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mime&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;PULL_EXTENSIONS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&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;lower&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;suffix&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;mime&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only names starting with &lt;code&gt;YYYY-MM-DD&lt;/code&gt; are picked up, and the push target itself (anything starting with &lt;code&gt;SHARED-MEMORY&lt;/code&gt;) is explicitly excluded. Without that exclusion, the next pull would pick up the file you just pushed as an "unknown external note," creating a loop.&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;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="n"&gt;info&lt;/span&gt; &lt;span class="o"&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;stat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;follow_symlinks&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;if&lt;/span&gt; &lt;span class="n"&gt;stat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;S_ISLNK&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;st_mode&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;stat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;S_ISREG&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;st_mode&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;counts&lt;/span&gt;&lt;span class="p"&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="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
        &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="bp"&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="n"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mime&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;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="n"&gt;MAX_PULL_FILES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Symlinks and non-regular files are rejected here, and the candidate list stops at 50. Even with thousands of files in the folder, &lt;code&gt;scandir&lt;/code&gt; keeps the scan cheap, and at most 50 files are actually read.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reusing secret screening and symlink safety checks
&lt;/h2&gt;

&lt;p&gt;Each note picked up by pull goes straight into &lt;code&gt;m.stage_note()&lt;/code&gt;, which already exists in &lt;code&gt;memory-cloud-sync.py&lt;/code&gt;. The hyphen in the filename rules out &lt;code&gt;import memory-cloud-sync&lt;/code&gt;, so the module is loaded by path with &lt;code&gt;importlib&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;_CLOUD_SYNC_PATH&lt;/span&gt; &lt;span class="o"&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;with_name&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;memory-cloud-sync.py&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;_spec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;importlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;util&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;spec_from_file_location&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;memory_cloud_sync_for_mount_sync&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_CLOUD_SYNC_PATH&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;_spec&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;_spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;loader&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;# pragma: no cover - defensive only
&lt;/span&gt;    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ImportError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;memory-cloud-sync.py could not be loaded&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;importlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;util&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;module_from_spec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_spec&lt;/span&gt;&lt;span class="p"&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;modules&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;_spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;
&lt;span class="n"&gt;_spec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;loader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec_module&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This lets the script &lt;strong&gt;reuse without duplicating a single line of code&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;m.secure_read&lt;/code&gt;: reads with an fstat match check before and after open&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;m.has_secret&lt;/code&gt; / &lt;code&gt;m.is_unsafe&lt;/code&gt;: a regex bank covering AWS keys, GitHub PATs, &lt;code&gt;sk-&lt;/code&gt;-style API keys, JWT shapes, Slack tokens, Bearer headers, emails, phone numbers, and medical/whistleblowing terms&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;m.stage_note&lt;/code&gt;: saves safe notes to &lt;code&gt;AI/INBOX/cloud/&amp;lt;sha256&amp;gt;.md&lt;/code&gt; with provenance, and writes unsafe ones to &lt;code&gt;quarantine/&lt;/code&gt; as JSON that leaves out the body&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Split that logic across two places and eventually you fix one copy and forget the other, so keeping it in one place is deliberate.&lt;/p&gt;

&lt;h2&gt;
  
  
  A single-run lock to prevent concurrent runs
&lt;/h2&gt;

&lt;p&gt;launchd starts the script every 5 minutes, so if the previous run hasn't finished, the new one has to skip immediately.&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;lock_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;vault&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AI&lt;/span&gt;&lt;span class="sh"&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;.runtime&lt;/span&gt;&lt;span class="sh"&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;memory-drive-mount-sync.lock&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ensure_directory_no_symlinks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lock_path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;lock_fd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;acquire_lock_fd&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lock_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="bp"&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;fcntl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lock_fd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fcntl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LOCK_EX&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;fcntl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LOCK_NB&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="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="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lock_fd&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;locked&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="mi"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;acquire_lock_fd&lt;/code&gt; refuses to proceed if the lock file itself is a symlink, then opens it with &lt;code&gt;O_NOFOLLOW&lt;/code&gt;. If the non-blocking &lt;code&gt;flock&lt;/code&gt; can't be acquired, the script prints &lt;code&gt;"locked"&lt;/code&gt; and exits cleanly with &lt;code&gt;exit 0&lt;/code&gt; rather than raising an error. The key point: a concurrent start is expected, not a failure.&lt;/p&gt;

&lt;p&gt;An unusable mount (Drive app not running, not mounted, etc.) is handled separately too:&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;check_mount_dir&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;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="bp"&gt;None&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;info&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="nf"&gt;stat&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="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="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;MountUnavailableError&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;exc&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;stat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;S_ISDIR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;st_mode&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;MountUnavailableError&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;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;access&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;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;R_OK&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;X_OK&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;MountUnavailableError&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This returns &lt;code&gt;mount_unavailable&lt;/code&gt; with &lt;code&gt;exit 3&lt;/code&gt;, a different code from the lock case (&lt;code&gt;"locked"&lt;/code&gt;, exit 0) and from general errors (&lt;code&gt;exit 2&lt;/code&gt;). That lets you tell three cases apart mechanically in the logs: "still running," "mount is gone," and "broke mid-run."&lt;/p&gt;

&lt;h2&gt;
  
  
  launchd config: every 5 minutes, low priority
&lt;/h2&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;12&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;ThrottleInterval&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;60&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;It starts every 5 minutes (&lt;code&gt;StartInterval=300&lt;/code&gt;), and &lt;code&gt;LowPriorityIO&lt;/code&gt; plus &lt;code&gt;Nice 12&lt;/code&gt; lower its disk I/O and CPU priority. Since the sync uses no network, the only real costs are local disk I/O and the CPU time for &lt;code&gt;memory-bridge.py build&lt;/code&gt;, so a priority that stays out of the way of foreground work is enough. &lt;code&gt;ThrottleInterval=60&lt;/code&gt; is launchd's minimum restart interval, so even a crash loop can't restart more often than once a minute.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Overwriting via rename with &lt;code&gt;os.replace()&lt;/code&gt; breaks the Drive file id&lt;/strong&gt; → switched to &lt;code&gt;seek(0)&lt;/code&gt; + &lt;code&gt;write&lt;/code&gt; + &lt;code&gt;truncate&lt;/code&gt; on the same fd&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TOCTOU: the file gets swapped between check and open&lt;/strong&gt; → compare &lt;code&gt;(st_dev, st_ino)&lt;/code&gt; from &lt;code&gt;lstat&lt;/code&gt; and &lt;code&gt;fstat&lt;/code&gt;, and abort on mismatch&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The next pull picks up the file you just pushed&lt;/strong&gt; → &lt;code&gt;classify_pull_candidate&lt;/code&gt; explicitly excludes names starting with &lt;code&gt;SHARED-MEMORY&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A diff in the &lt;code&gt;updated:&lt;/code&gt; line alone triggers a write every run&lt;/strong&gt; → strip the &lt;code&gt;updated:&lt;/code&gt; line before checking whether content changed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hyphens in the filename make &lt;code&gt;import&lt;/code&gt; impossible&lt;/strong&gt; → load by path with &lt;code&gt;importlib.util.spec_from_file_location&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write failures when the mount is unavailable get buried among "errors"&lt;/strong&gt; → split &lt;code&gt;mount_unavailable&lt;/code&gt; out into its own exit code&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The Google Drive for Desktop local mount can serve as a plain file drop for syncing, with no auth layer added&lt;/li&gt;
&lt;li&gt;Drive tracks file ids by inode, so &lt;strong&gt;never use &lt;code&gt;os.replace()&lt;/code&gt;. Overwrite through the same fd&lt;/strong&gt; to keep the file object intact&lt;/li&gt;
&lt;li&gt;With no auth layer, POSIX-level care (&lt;strong&gt;fstat/inode matching, O_NOFOLLOW, flock&lt;/strong&gt;) provides the safety instead&lt;/li&gt;
&lt;li&gt;Pull is narrowed by date-based name, extension, file count, and size, and secret screening plus symlink safety checks are &lt;strong&gt;reused from the existing implementation&lt;/strong&gt; so there's only one copy to maintain&lt;/li&gt;
&lt;li&gt;Concurrent runs, an unavailable mount, and general errors each get &lt;strong&gt;their own exit code&lt;/strong&gt;, so logs can be triaged mechanically&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next time, I plan to write about extending this single-run lock and staging quarantine approach to the other sync paths.&lt;/p&gt;

&lt;p&gt;Have you run into file identity problems when writing to a synced folder like Drive, Dropbox, or iCloud? How did you work around them?&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>security</category>
      <category>python</category>
    </item>
    <item>
      <title>I Don't Trust the LLM That Writes My Shared Memory: 3 Regex Gates Before Claude Code Auto-Commits to My Obsidian Vault</title>
      <dc:creator>Lily</dc:creator>
      <pubDate>Thu, 24 Sep 2026 00:00:02 +0000</pubDate>
      <link>https://dev.to/bokuwalily/i-dont-trust-the-llm-that-writes-my-shared-memory-3-regex-gates-before-claude-code-auto-commits-3i77</link>
      <guid>https://dev.to/bokuwalily/i-dont-trust-the-llm-that-writes-my-shared-memory-3-regex-gates-before-claude-code-auto-commits-3i77</guid>
      <description>&lt;p&gt;Every 10 minutes, an unattended job has an LLM summarize my AI conversation logs, then commits and pushes the result to my Obsidian Vault. No human reviews it first. The obvious risk: the model's output ends up in git history, and other AI sessions read it later as memory. In the naive version, whatever the model returned went straight into the Vault. Now I treat that output as untrusted input. The model runs with no write tools, and the output goes through the same regex checks at three separate points before anything reaches a commit.&lt;/p&gt;

&lt;p&gt;Last time, I wrote about &lt;a href="https://dev.to/bokuwalily/4-days-3-wasted-calls-per-run-my-retry-loop-mistook-a-quota-limit-message-for-a-too-short-4l23"&gt;how I misread a quota-limit message as "content too short" and failed three times in a row&lt;/a&gt;. This post covers something that comes one step earlier: &lt;strong&gt;a design that treats the AI's own output as untrusted input&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: unattended commits of conversation-log summaries to my Vault
&lt;/h2&gt;

&lt;p&gt;Conversation logs from Claude Code and Codex keep piling up under &lt;code&gt;~/Documents/my-knowledge-base/raw/&lt;/code&gt; (&lt;code&gt;conversations/&lt;/code&gt;, &lt;code&gt;codex-conversations/&lt;/code&gt;, &lt;code&gt;manus-conversations/&lt;/code&gt;). Every 10 minutes a job picks them up, has a model summarize them, writes the summary back to &lt;code&gt;AI/SESSION-STATE.md&lt;/code&gt; in my Obsidian Vault, and runs git commit and push. Nobody checks it along the way.&lt;/p&gt;

&lt;p&gt;The scariest part is that &lt;strong&gt;the LLM doing the summarizing can itself return untrustworthy output&lt;/strong&gt;. A conversation log might contain an API key or auth URL I pasted in. It might also contain prompt-injection text that came in through another AI or a web page. If I write that into the Vault without checking it, secrets end up in git history, or the next AI session that reads the Vault follows fake instructions.&lt;/p&gt;

&lt;p&gt;The previous post was about misreading a quota-limit message as real body text, but the root cause is the same: &lt;strong&gt;if you treat whatever text an AI returns as meaningful content by default, things break&lt;/strong&gt;. This post covers the countermeasures: a sandbox on the generation side and a regex gate on the receiving side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Overview: isolate generation, distrust everything on receipt
&lt;/h2&gt;

&lt;p&gt;The pipeline is two files: &lt;code&gt;memory-reflect.sh&lt;/code&gt; (a bash script launched by launchd) and &lt;code&gt;normalize-memory-reflection.py&lt;/code&gt; (the receiving gate).&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;th&gt;Owner&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Generation&lt;/td&gt;
&lt;td&gt;Have a maximally de-privileged Claude produce the summary&lt;/td&gt;
&lt;td&gt;&lt;code&gt;memory-reflect.sh&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Validation&lt;/td&gt;
&lt;td&gt;Re-validate the returned JSON without trusting any of it&lt;/td&gt;
&lt;td&gt;&lt;code&gt;normalize-memory-reflection.py&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Double check&lt;/td&gt;
&lt;td&gt;Re-scan the written files and the git diff with regex&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;memory-reflect.sh&lt;/code&gt; (second half)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; The key is not to assume that restricting the generation side makes things safe. Even with the tools limited to &lt;code&gt;--tools Read,Grep,Glob&lt;/code&gt;, you can't control &lt;strong&gt;the content of the text the model returns&lt;/strong&gt;. So the receiving side validates it again, zero-trust.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Layer 1: Summarize with a Claude that has no privileges
&lt;/h2&gt;

&lt;p&gt;Here's the invocation:&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;$TIMEOUT_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;$MODEL_TIMEOUT_SECONDS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CLAUDE_BIN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--print&lt;/span&gt; &lt;span class="nt"&gt;--no-session-persistence&lt;/span&gt; &lt;span class="nt"&gt;--model&lt;/span&gt; sonnet &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--setting-sources&lt;/span&gt; project &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;--tools&lt;/span&gt; &lt;span class="s1"&gt;'Read,Grep,Glob'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output-format&lt;/span&gt; json &lt;span class="nt"&gt;--json-schema&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SCHEMA&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--system-prompt&lt;/span&gt; &lt;span class="s1"&gt;'You are a read-only structured memory extractor. Follow the user prompt exactly and return only schema-compliant JSON.'&lt;/span&gt; &lt;span class="se"&gt;\&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;$RESULT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;--tools 'Read,Grep,Glob'&lt;/code&gt;: no Bash, Edit, Write, or MCP. The model can't write files and can't reach outside.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--strict-mcp-config --mcp-config '{"mcpServers":{}}'&lt;/code&gt;: forces MCP server connections to zero in code, instead of relying on me having cleaned up my config.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--output-format json --json-schema "$SCHEMA"&lt;/code&gt;: no free-form text. If the output doesn't match the schema, the call itself fails.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;timeout 120&lt;/code&gt; (&lt;code&gt;MODEL_TIMEOUT_SECONDS&lt;/code&gt;): a wall-clock cap in case it hangs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's the schema (reformatted for readability):&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="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"additionalProperties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&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;"required"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"session_state"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"proposals"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sources_reviewed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"limitations"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"properties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&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;"session_state"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"maxLength"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="w"&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;"proposals"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"array"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"maxItems"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"items"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"additionalProperties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&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;"required"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"kind"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sources"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"confidence"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"expiry"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"properties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&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;"kind"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&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;"enum"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"durable-candidate"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"provisional-candidate"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"wiki-update-candidate"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"do-not-store"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&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;"confidence"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&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;"enum"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"high"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"medium"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"low"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&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;"sources_reviewed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"array"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"maxItems"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="w"&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;"limitations"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&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;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"array"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"maxItems"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&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;p&gt;The prompt adds a second layer of instructions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Never include or paraphrase: credentials, API keys, tokens, passwords, cookies,
auth URLs/codes, financial account or card data, medical detail, direct contact
data, private messages, meeting links, detailed private schedules, unconfirmed
legal/financial terms, or unverified allegations.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;However, &lt;strong&gt;this is only a request to the model. Telling it "don't write secrets" in the prompt doesn't guarantee it will comply.&lt;/strong&gt; That's why the layers that follow do the real work.&lt;/p&gt;

&lt;p&gt;If the call fails, the script tries a local Qwen fallback (&lt;code&gt;ollama-memory-json.py&lt;/code&gt;). If that also fails, it writes &lt;code&gt;{}&lt;/code&gt; and treats the result as empty. This is an unattended job, so I'd rather it fail safely with no output than halt when something breaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 2: The regex gate, zero-trust validation of model output
&lt;/h2&gt;

&lt;p&gt;The docstring of &lt;code&gt;normalize-memory-reflection.py&lt;/code&gt; says:&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="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Normalize untrusted local LLM reflection output into a safe schema.

On malformed or unsafe output, emit a provenance-only fallback. The fallback
never summarizes transcript contents and therefore cannot promote invented or
sensitive details into the shared session state.
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It states outright that LLM output is untrusted. If validation fails, it returns &lt;strong&gt;a fallback that never summarizes anything from the transcripts&lt;/strong&gt;. Most of the work happens in three stages.&lt;/p&gt;

&lt;h3&gt;
  
  
  ① Don't trust JSON parsing itself
&lt;/h3&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;unique_object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pairs&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;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="n"&gt;Any&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="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Any&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="nb"&gt;dict&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;Any&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;key&lt;/span&gt;&lt;span class="p"&gt;,&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;pairs&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;in&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;ReflectionError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;duplicate-json-key&lt;/span&gt;&lt;span class="sh"&gt;"&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="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;value&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;With &lt;code&gt;object_pairs_hook=unique_object&lt;/code&gt;, any JSON object with duplicate keys is rejected. The standard &lt;code&gt;json.loads&lt;/code&gt; silently keeps the last value, so this closes off parser-differential attacks where something like &lt;code&gt;{"kind":"do-not-store","kind":"durable-candidate"}&lt;/code&gt; slips past the check.&lt;/p&gt;

&lt;p&gt;Envelope unwrapping is tightly restricted here too:&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;unwrap_structured_output&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="n"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;depth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&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;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="bp"&gt;...&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="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;structured_output&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;result&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;nested&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;value&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="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;nested&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="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;unwrap_structured_output&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nested&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&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;nested&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;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;FENCED_JSON_RE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fullmatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nested&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="c1"&gt;# A result string may be exactly JSON or a single explicit JSON fence.
&lt;/span&gt;            &lt;span class="c1"&gt;# Do not scrape an object embedded in arbitrary untrusted prose.
&lt;/span&gt;            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;unwrap_structured_output&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;parse_json_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;match&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&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;match&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;nested&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&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;ReflectionError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;structured-output-missing&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;As the comment says, it deliberately does &lt;strong&gt;not&lt;/strong&gt; fish JSON-looking fragments out of surrounding prose, where prompt injection could have planted them. It only accepts the &lt;code&gt;--output-format json&lt;/code&gt; CLI envelope (the &lt;code&gt;structured_output&lt;/code&gt;/&lt;code&gt;result&lt;/code&gt; keys) or exactly one Markdown JSON fence. Nesting is cut off at &lt;code&gt;depth &amp;gt; 3&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  ② Reject content with regex
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;SECRET_RE&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;(?i)(?:\bsk-[A-Za-z0-9_-]{16,}\b|\b(?:api[_ -]?key|password|secret|bearer|token)\s*[:=]\s*[^\s`&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;\"]{8,})&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;MEETING_RE&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;https?://(?:meet\.google\.com|zoom\.us/j|teams\.microsoft\.com)/[^\s)\]&amp;gt;]+&lt;/span&gt;&lt;span class="sh"&gt;"&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="n"&gt;I&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;AUTH_LINK_RE&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;https?://[^\s)\]&amp;gt;]*(?:oauth|auth|signin|login)[^\s)\]&amp;gt;]*|https?://[^\s)\]&amp;gt;]*[?&amp;amp;](?:code|token|access_token|id_token)=[^\s&amp;amp;#)\]&amp;gt;]+&lt;/span&gt;&lt;span class="sh"&gt;"&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="n"&gt;I&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;FINANCIAL_RE&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;(?i)\b(?:card\s*number|credit\s*card|bank\s*account|routing\s*number|iban)\b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;HEALTH_RE&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;(?i)\b(?:medical\s+record|diagnosis|medication|patient\s+detail|health\s+condition)\b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;CONTACT_RE&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;(?i)\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b|\b(?:\+?\d[\d .()/-]{7,}\d)\b&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;INSTRUCTION_RE&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;(?i)\b(?:ignore|disregard|override)\b.{0,80}\b(?:previous|prior|system|developer)\b&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;\b(?:instructions?)\b&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;|\b(?:system\s+prompt|developer\s+message|jailbreak)\b&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;The interesting part is how &lt;code&gt;CONTACT_RE&lt;/code&gt; is used. A regex that rejects phone-number-like digit sequences will almost certainly flag dates like &lt;code&gt;2026-09-23&lt;/code&gt; too. So the actual check looks 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;is_safe&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="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="n"&gt;contact_candidate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ISO_DATE_RE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[DATE]&lt;/span&gt;&lt;span class="sh"&gt;"&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;if&lt;/span&gt; &lt;span class="n"&gt;CONTACT_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;contact_candidate&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="ow"&gt;not&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;pattern&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;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;pattern&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;SECRET_RE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;MEETING_RE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;AUTH_LINK_RE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;FINANCIAL_RE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;HEALTH_RE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;INSTRUCTION_RE&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;Replacing ISO dates with &lt;code&gt;[DATE]&lt;/code&gt; before the phone-number check prevents those false positives. &lt;code&gt;INSTRUCTION_RE&lt;/code&gt; targets the stock phrases of prompt injection, like "ignore previous instructions", "system prompt", and "jailbreak". It's there so that &lt;strong&gt;even if instructions injected in another session show up in a conversation log, they never get adopted as session_state&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  ③ Restrict citations to the list of files actually provided
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;raw_reviewed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sources_reviewed&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="c1"&gt;# The model may only cite the source manifest generated by the wrapper.
&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;item&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;sources&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;raw_reviewed&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;ReflectionError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sources-reviewed-not-in-manifest&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;&lt;code&gt;sources&lt;/code&gt; is the file list that &lt;code&gt;memory-reflect.sh&lt;/code&gt; built with &lt;code&gt;find&lt;/code&gt; and passed in as an argument (&lt;code&gt;SOURCE_LIST&lt;/code&gt;). If the model claims "I also read this file" and that file isn't in the list, the output is rejected immediately. Each proposal's &lt;code&gt;sources&lt;/code&gt; must likewise be a subset of the files provided. This closes off the trick where &lt;strong&gt;the model fabricates sources to sneak its own "facts" in as durable-candidates&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  If validation fails, say nothing
&lt;/h3&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;fallback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sources&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;reason&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="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Any&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;status&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;fallback&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;session_state&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;## Automatic archive update&lt;/span&gt;&lt;span class="se"&gt;\n\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;- &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;sources&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;件の新しいlocal transcriptを取得した。&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;structured reflectionの出力が検証できなかったため、内容は共有memoryへ昇格していない。&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
            &lt;span class="bp"&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;proposals&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="bp"&gt;...&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(The Japanese strings read roughly: "Fetched N new local transcripts. The structured reflection output could not be validated, so its content was not promoted to shared memory.")&lt;/p&gt;

&lt;p&gt;When validation fails, the fallback &lt;code&gt;session_state&lt;/code&gt; only says how many transcripts were fetched and &lt;strong&gt;summarizes nothing from their contents&lt;/strong&gt;. The safe option here isn't to produce a harmless-looking summary. It's to say nothing about the content at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 3: Scan the files and the git diff again
&lt;/h2&gt;

&lt;p&gt;Even after normalization passes, &lt;code&gt;memory-reflect.sh&lt;/code&gt; runs two more regex checks of the same kind.&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;# Last line of defense: never copy likely secret values to shared state.&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'(api[_ -]?key|password|secret|bearer|token)[[:space:]]*[:=][[:space:]]*[^[:space:]]{8,}'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CANDIDATE&lt;/span&gt;&lt;span class="s2"&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="s1"&gt;'reflection blocked: secret-like value in candidate state'&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi
&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;$CANDIDATE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATE_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This grep runs separately on the candidate file for &lt;code&gt;SESSION-STATE.md&lt;/code&gt; and on the proposal file for &lt;code&gt;AI/INBOX/auto/&lt;/code&gt;. Then, right before committing, it re-scans &lt;strong&gt;the staged diff itself&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;git add &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATE_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$INBOX_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
git diff &lt;span class="nt"&gt;--cached&lt;/span&gt; &lt;span class="nt"&gt;--check&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATE_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$INBOX_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
git diff &lt;span class="nt"&gt;--cached&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATE_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$INBOX_FILE&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;-E&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'(api[_ -]?key|password|secret|bearer|token)[[:space:]]*[:=][[:space:]]*[^[:space:]]{8,}'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'reflection blocked: secret-like value in staged diff'&lt;/span&gt;
  git restore &lt;span class="nt"&gt;--staged&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATE_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$INBOX_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
  exit &lt;/span&gt;1
&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; git diff &lt;span class="nt"&gt;--cached&lt;/span&gt; &lt;span class="nt"&gt;--quiet&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATE_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$INBOX_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;git commit &lt;span class="nt"&gt;--only&lt;/span&gt; &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"chore(memory): reflect local sessions"&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$STATE_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$INBOX_FILE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null
  git push origin main &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Running the same regex three times (inside the normalizer, on the candidate file, and on the staged diff) looks redundant, but &lt;strong&gt;each check sees different input&lt;/strong&gt;. The normalizer sees the model's raw JSON. The second check sees the file after it's been rendered as Markdown. The third sees the exact diff going into git. Formatting and Markdown conversion can change how escapes and newlines are handled, which could let something slip past the first check. So the last line of defense has to look at the actual bytes being committed.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;git commit --only&lt;/code&gt; matters too. I also edit this Vault repo by hand, so the job might run while I have other changes that I forgot to stage. &lt;code&gt;--only&lt;/code&gt; limits the commit to the two files added in this run, so unrelated changes never get swept in.&lt;/p&gt;

&lt;h2&gt;
  
  
  The launchd configuration
&lt;/h2&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;600&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;ThrottleInterval&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;60&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;12&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;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;MEMORY_REFLECT_MIN_INTERVAL_SECONDS&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;300&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;&lt;code&gt;StartInterval=600&lt;/code&gt; launches the job every 10 minutes. Two guards on the script side prevent overlapping runs even if a previous one takes longer: a debounce (&lt;code&gt;MIN_INTERVAL&lt;/code&gt;, overridden to 300 seconds in the plist) and a lock directory (if &lt;code&gt;mkdir "$LOCK"&lt;/code&gt; fails, the run skips immediately). &lt;code&gt;LowPriorityIO&lt;/code&gt; and &lt;code&gt;Nice 12&lt;/code&gt; keep this background job from taking CPU and I/O away from foreground work.&lt;/p&gt;

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

&lt;p&gt;Here are the problems I actually ran into, as recorded in the code comments:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The phone-number regex flagged dates as false positives&lt;/strong&gt; → replace dates with &lt;code&gt;[DATE]&lt;/code&gt; via &lt;code&gt;ISO_DATE_RE&lt;/code&gt; before applying &lt;code&gt;CONTACT_RE&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Duplicate JSON keys could bypass the check&lt;/strong&gt; → reject duplicate keys as soon as they're found, using &lt;code&gt;object_pairs_hook&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trusting the model's own claims about its sources isn't validation&lt;/strong&gt; → force &lt;code&gt;sources_reviewed&lt;/code&gt;/&lt;code&gt;proposal.sources&lt;/code&gt; to be a subset of the files actually provided&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ValueError&lt;/code&gt; was only caught after ad-hoc extraction paths, so the script could crash partway through&lt;/strong&gt; → this past bug is noted in the code. It's now fixed: errors are normalized to &lt;code&gt;ReflectionError&lt;/code&gt;, which returns a deterministic failure code&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;With the regex check in only one place, things could slip through during formatting or Markdown conversion&lt;/strong&gt; → repeat the same check in three places: normalizer output, the written files, and the staged diff&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Commits could sweep in unrelated changes&lt;/strong&gt; → &lt;code&gt;git commit --only&lt;/code&gt; limits the commit to the two files&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;On the generation side, &lt;code&gt;--tools Read,Grep,Glob&lt;/code&gt;, disabled MCP, and an enforced JSON Schema mean &lt;strong&gt;the model has no write capability at all&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;On the receiving side, output is treated as fully untrusted and validated in three stages: hardened JSON parsing, regex detection of secrets, contact info, and prompt injection, and restricting citations to the manifest.&lt;/li&gt;
&lt;li&gt;When validation fails, the fallback chooses to summarize nothing, erring on the side of safety.&lt;/li&gt;
&lt;li&gt;The same regex checks run again when writing the files and right before the git commit. &lt;strong&gt;Validation shouldn't stop at one checkpoint.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;git commit --only&lt;/code&gt; keeps the unattended job from sweeping up a human's uncommitted changes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next time, I plan to write about what happens after &lt;code&gt;SESSION-STATE.md&lt;/code&gt; and the INBOX proposals pass these layers: how a human actually reviews them and promotes them into durable memory.&lt;/p&gt;

&lt;p&gt;If you let an LLM write into shared memory or a repo unattended, where do you put your trust boundary: on the generation side, the receiving side, or right before the commit?&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>security</category>
      <category>automation</category>
      <category>obsidian</category>
    </item>
    <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>
  </channel>
</rss>
