0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

⚙️ 夜間インジェストが3日止まった原因と、サブステップ分割で直した話

0
Posted at

月10万の大学生から掛け持ち月60万、会社都合で0に戻り、そこから半年でClaude Code自律環境を建て直して月商120万——この連載はその環境の中身を実コードと実数字で公開します。

なぜこの仕組みが効くのか

Claude Codeを副業の主力に据えてから最初にぶつかった壁は「文脈の消耗」です。

セッションをまたぐたびに、前回の会話で決めたアーキテクチャ、試して失敗した実装、「この方向では行かない」と決断した理由——そのすべてを次のセッションで説明し直す必要があります。1日8時間使えば1週間で数十万トークンのやりとりが蒸発する。モデルがいくら賢くても、文脈が毎回ゼロに戻るのでは個人事業の基盤にはなりません。

この問題を根本から解決するのが「外付け脳」という考え方です。Claudeとのすべての会話ログをリアルタイムで保存し、毎晩そのログをObsidian vault(Markdownベースの個人wiki)へ自動で取り込んで構造化します。プロジェクトの決定は wiki/projects/ に、技術的教訓は wiki/learning/ に、今週の論点は wiki/hot.md に——セッションをまたいで知識が積まれ続ける状態を機械的に維持するわけです。

作業ではなく環境、これが核心です。

普通の個人開発者は「今日何を作るか」を毎朝考えます。この環境では、MacBookを開いた瞬間に「昨夜の自動処理が今朝のブリーフに昨日の決定を整理してある」状態がある。前日にClaudeと詰めたAPIの設計方針がmarkdownとして残っており、Codexに委譲して走らせた実装の教訓が既存記事に追記されている。深夜に気づいた仕様の矛盾が、翌朝のブリーフに「見落としてる繋がり」として浮かび上がっている。

この「環境が自分より先に動いている」感覚が、スケールの鍵です。案件や個人開発タスクの切り替えコストが激減し、「あの件どこまで進めたっけ」という問いへの答えがwikiにあるので、毎朝の再起動コストがほぼゼロになります。月商120万という数字の根底には、この文脈コストの削減があります。

もう一つ強調したいのが自己修復性です。自動化は必ず詰まります。ネット未接続の状態でlaunchdが起動する、APIが一時的にハングする、macOSの権限保護でスクリプトが弾かれる——こうした障害を人間が毎晩監視するのでは本末転倒です。「詰まっても次のスロットがリトライする」構造を最初から設計に組み込んでいるので、障害に気づかない間に環境が回復しています。実際にこの設計がどこまで有効か、後半で具体的な障害事例と数字を出します。

全体の流れ

この環境の中核コンポーネントは2つです。~/.claude/scripts/vault-auto-ingest.sh(処理本体)と、~/Library/LaunchAgents/com.shun.vault-auto-ingest.plist(定刻発火の定義)。この2ファイルの組み合わせが、毎晩の「ログ→vault」パイプラインを回しています。

パイプライン全体図

layer 1: 源泉ログ
──────────────────────────────────────────────────────────
  Claude会話ログ ──┐
  Codex会話ログ  ──┤→ extract_conversations.py (step1)
                   └→ ~/Documents/my-knowledge-base/raw/
                        ├── conversations/       (Claude)
                        └── codex-conversations/ (Codex)

launchd com.shun.vault-auto-ingest
  発火スロット: 4:55 / 8:20 / 10:45 / 12:15
──────────────────────────────────────────────────────────

layer 4: Obsidian Vault
                         ┌── step2a (Claude, timeout 1500s)
vault-auto-ingest.sh ───┤── step2b (Codex,  timeout 1500s)
                         └── step2.5 (brief,  timeout 900s)
                                │
                         wiki/ ─┤─ projects/ career/ learning/ life/
                                ├─ hot.md  (直近コンテキスト)
                                ├─ index.md
                                └─ today-brief.md → Desktop/Daily Brief/

step1でClaude/Codexの会話ログをsource別フォルダへ展開し、step2a・step2bでそれぞれをvaultに取り込み、step2.5で今日のブリーフを生成する。最後にgit commit + pushしてvaultを安全網としてバックアップします。

launchd の4スロット発火

com.shun.vault-auto-ingest.plist の発火定義は以下の通りです。

<key>StartCalendarInterval</key>
<array>
  <dict>
    <key>Hour</key><integer>4</integer>
    <key>Minute</key><integer>55</integer>
  </dict>
  <dict>
    <key>Hour</key><integer>8</integer>
    <key>Minute</key><integer>20</integer>
  </dict>
  <dict>
    <key>Hour</key><integer>10</integer>
    <key>Minute</key><integer>45</integer>
  </dict>
  <dict>
    <key>Hour</key><integer>12</integer>
    <key>Minute</key><integer>15</integer>
  </dict>
</array>

4スロット持つ理由は「スリープ凍結への対策」です。4:55はMacがAC接続で深夜に自動起床するメインスロットです。しかしバッテリー駆動のとき、caffeinate -s(システムスリープ防止)はAC接続時のみ有効で、バッテリーでは無効になります。その結果、スクリプトが処理の途中で蓋閉じスリープに入って凍結し、timeout --kill-after=30 で刈り取られます。8:20・10:45・12:15は「凍結したランをやり直す」ためのキャッチアップスロットです。

成功済みの日はどのスロットも1行で完了します。スクリプト冒頭の当日完了マーカーがそれを担います。

TODAY=$(date +%Y%m%d)
DONE_MARKER="$HOME/.claude/logs/.vault-ingest-done-${TODAY}"

# 本日分が既に成功していれば即終了(catch-upスロットの空振り。ログも汚さない)
[ -f "$DONE_MARKER" ] && exit 0

ロックとcaffeinate

スリープ防止と二重実行防止の実装です。

# スクリプト自身をcaffeinate配下で再起動(実行中スリープ防止)
if [ -z "${CAFFEINATED:-}" ]; then
  exec /usr/bin/caffeinate -i -s env CAFFEINATED=1 /bin/bash "$0" "$@"
fi

# mkdirアトミック操作でロック(staleはpid死活で自動回収)
LOCKDIR="$HOME/.claude/locks/vault-auto-ingest.lock"
if ! /bin/mkdir "$LOCKDIR" 2>/dev/null; then
  oldpid=$(cat "$LOCKDIR/pid" 2>/dev/null || true)
  if [ -n "${oldpid:-}" ] && kill -0 "$oldpid" 2>/dev/null; then
    echo "[$(date '+%F %T')] 別インスタンス実行中(pid=${oldpid}) — skip" >> "$LOG"
    exit 0
  fi
  rm -rf "$LOCKDIR"
  /bin/mkdir "$LOCKDIR" 2>/dev/null || exit 0
fi
echo $$ > "$LOCKDIR/pid"
trap 'rm -rf "$LOCKDIR"' EXIT INT TERM

CAFFEINATED フラグを環境変数で引き継ぐことで「caffeinate → 自分自身 → caffeinate → 自分自身」の無限再帰を防いでいます。mkdirによるロックはシェルで最もシンプルな排他制御で、mkdir自体がアトミック操作なのでレースコンディションが発生しません。staleロック(プロセスが死んでロックだけ残った状態)はkill -0でpidの生死を確認してから自動回収します。

step2: Vaultへの取り込み関数

パイプラインの主役が ingest_src() 関数です。マーカーファイルの存在/不在で「今日やったかどうか」を判定し、未完了の場合だけ claude -p を発火します。

ingest_src() {  # $1=マーカー $2=ソースdir $3=ソース名 $4=timeout秒 $5=追加指示
  local marker="$1" src="$2" name="$3" to="$4" extra="$5"
  [ -f "$marker" ] && {
    echo "[$(date '+%F %T')] step2($name) は本日実施済み — skip" >> "$LOG"
    return 0
  }
  cd "$VAULT" && run_to "$to" "$CLAUDE" -p \
"$VAULT/CLAUDE.md と $VAULT/wiki/CLAUDE.md のルールに従え。${src} に直近28時間で
追加・更新されたファイル(${name}由来の会話ログ)だけを対象に、このVaultの wiki/ を
更新せよ。手順: 1)新しい知識・進捗・決定・教訓を抽出 2)ドメイン別構造を厳守し
適切な記事に追記・新規作成 3)wiki/index.md と wiki/hot.md も追記更新。${extra}" \
    --dangerously-skip-permissions >> "$LOG" 2>&1 \
    && { touch "$marker"; echo "[...] step2($name) 完了" >> "$LOG"; return 0; } \
    || { echo "[...] WARN: step2($name) 失敗/timeout(次スロットで再試行)" >> "$LOG"; return 1; }
}

この関数が呼ばれる箇所が、step2の実体です。

# 2a: Claude Code ログを先に処理(hot.mdの土台を作る)。timeout 1500秒(25分)
ingest_src "$STEP2A_MARKER" "$KB/raw/conversations/" "claude" 1500 ""

# 2b: Codexログを統合。同じく1500秒。2aの結果を踏まえて重複話題は追記でまとめる
ingest_src "$STEP2B_MARKER" "$KB/raw/codex-conversations/" "codex" 1500 \
  "Codex由来でも既存記事に統合し、Claude側と重複する話題は新記事を作らず追記でまとめろ。"

# 両サブが済んだ時だけ「本日ingest完了」マーカーを立てる
# 片方がtimeoutなら次スロットが残りだけ再試行する
[ -f "$STEP2A_MARKER" ] && [ -f "$STEP2B_MARKER" ] && touch "$STEP2_MARKER"

3本のマーカーが独立して存在することが、この設計の肝です。

STEP2A_MARKER  = ~/.claude/logs/.vault-ingest-step2a-claude-YYYYMMDD
STEP2B_MARKER  = ~/.claude/logs/.vault-ingest-step2b-codex-YYYYMMDD
STEP2_MARKER   = ~/.claude/logs/.vault-ingest-step2-done-YYYYMMDD

step2aが完了 → step2aマーカー作成。step2bが完了 → step2bマーカー作成。両方揃って初めてSTEP2_MARKERが作られ、以降のスロットはstep2全体をskipする。片方だけ完了した日の次スロットは「完了済みのサブをskip、未完了のサブだけ再試行」という動きをします。

マーカー冪等性の実証

この設計が実際に機能しているかを確認する方法は単純です。

ls ~/.claude/logs/.vault-ingest-step2*

実行結果(2026-07-16時点):

~/.claude/logs/.vault-ingest-step2-done-20260711
~/.claude/logs/.vault-ingest-step2-done-20260712
~/.claude/logs/.vault-ingest-step2-done-20260713
~/.claude/logs/.vault-ingest-step2-done-20260714
~/.claude/logs/.vault-ingest-step2a-claude-20260711
~/.claude/logs/.vault-ingest-step2a-claude-20260712
~/.claude/logs/.vault-ingest-step2a-claude-20260713
~/.claude/logs/.vault-ingest-step2a-claude-20260714
~/.claude/logs/.vault-ingest-step2b-codex-20260709
~/.claude/logs/.vault-ingest-step2b-codex-20260711
~/.claude/logs/.vault-ingest-step2b-codex-20260712
~/.claude/logs/.vault-ingest-step2b-codex-20260713
~/.claude/logs/.vault-ingest-step2b-codex-20260714

7日超のマーカーはスクリプト末尾の自動掃除で消えるため、過去7日分だけが残ります。ここで見えていることは3つです。

  1. step2a-claude と step2b-codex が別ファイルとして独立して存在する。片方が先に完了してもう片方を待つ必要がない。
  2. step2-done は両サブの完了を前提に作られる。07-11〜14は4日連続で両サブが完了し、done-markerも揃っている。
  3. 07-10のマーカーがない。この日は何らかの理由で両サブが完了しなかったことを意味します(詳細は後半で)。

なお、find "$HOME/.claude/logs" -maxdepth 1 -name '.vault-ingest-*' -mtime +7 -delete というクリーンアップが毎回スクリプト末尾で走るので、マーカーが無限に溜まることはありません。7日より古い完了の証拠は自動で消えます。

実装の詳細

launchd の PATH を「見てから補う」設計

launchd が起動するシェルは GUI 環境とは別プロセスです。~/.zshrc~/.bash_profile も読み込まれないため、Homebrew の /opt/homebrew/bin も、nvm 配下の Node.js も、最初から PATH に入っていません。

vault-auto-ingest.sh の冒頭には、この問題を解決するためのコードが入っています。

NODE_BIN=$(ls -d "$HOME"/.nvm/versions/node/*/bin 2>/dev/null | sort -V | tail -1)
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH"
[ -n "$NODE_BIN" ] && export PATH="${NODE_BIN}:$PATH"

重要なのは、Node のパスを バージョンを固定せずに動的に探している点です。sort -V(バージョンソート)の末尾を取ることで、将来 nvm で Node をアップグレードしても壊れません。パスを v24.13.0 のように固定すると、次のバージョンに移行した瞬間にスクリプトが死にます。実際、自分の環境ではかつてパスを固定して書いたスクリプトが複数ファイルに残っており、Node 更新のたびに探して直す羽目になっていました。この書き方に変えてから、その問題は消えました。

この PATH 補填は git のコミットフックのためでもあります。vault は git リポジトリで管理しており、git commit 時にフックが走ります。そのフックが Node 製のツールを呼ぶ構成になっているため、PATH に Node が入っていないとコミット自体が失敗します。git commit が失敗するとログに「commit-msg フック/node まわりを疑え」というエラーが出て、真因を取り違えた調査に30分以上溶かすことになります。

wake 直後の90秒ネット待機ループ

Mac が 4:55 にスリープから自動起床しても、Wi-Fi の接続確立には数秒から数十秒かかります。claude -p は API 呼び出しなので、ネットが繋がっていない状態で実行すると即座に失敗します。

net_ok=""
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18; do
  if /usr/bin/nc -z -G 3 1.1.1.1 443 2>/dev/null; then net_ok=1; break; fi
  sleep 5
done
[ -z "$net_ok" ] && echo "[$(date '+%F %T')] WARN: 網未接続のまま続行(失敗時は次スロットが再試行)" >> "$LOG"

18 回 × 5 秒 = 最大 90 秒待ちます。接続できれば即抜けるので、通常は 5 〜 15 秒で通過します。90 秒経っても繋がらない場合は警告をログに残してそのまま続行します。「ネット未接続なら即 exit 1」にしてしまうと、キャッチアップスロット(8:20 / 10:45 / 12:15)が繋がっている環境でリトライできるのに、最初から無駄に止まります。

確認先が 1.1.1.1:443(Cloudflare DNS)なのは、ダウンリスクがほぼゼロで、タイムアウト -G 3(3 秒)を設定しているためです。余計なDNS 解決も挟まない。

TCC プリフライトでサイレント失敗を防ぐ

macOS の TCC(プライバシー保護)は開発者を静かに殺します。launchd 配下で走るプロセスが ~/Documents 以下に書こうとすると、エラーも返さずに黙って失敗します。ログには何も残らない。FAILED_FILE も作られない。「成功も失敗もしていない」状態のまま朝を迎えます。

この問題への対策が、スクリプト本体の実行前に必ず行う TCC プリフライトです。

if ! ( cd "$VAULT" 2>/dev/null && git rev-parse --git-dir >/dev/null 2>&1 ); then
  echo "[$(date '+%F %T')] ❌ FDA未付与: launchdから '$VAULT' にアクセス不可(TCC保護)。" >> "$LOG"
  echo "    解決: システム設定 > プライバシーとセキュリティ > フルディスクアクセス で /bin/bash を許可。" >> "$LOG"
  notify_fail "FDA未付与: vault にアクセス不可(設定→フルディスクアクセス→/bin/bash)"
  exit 1
fi

git rev-parse --git-dir は vault が git リポジトリとして認識できるかを確認します。TCC で弾かれると cd は成功しても git の書き込みが失敗し、全体として偽陰性になります。これを step 1 の前に挿入しておくことで「FDA が付いていない日は、最初に大声でログを残して止まる」動きになります。サイレント失敗と大声の失敗は根本的に違います。後者は次の朝にすぐ原因がわかります。

FAILED ファイルによる失敗の可視化

claude -p が失敗したとき、ターミナルを開いていなければ気づきません。そのためにデスクトップへの FAILED ファイル通知が入っています。

FAILED_FILE="$HOME/Desktop/Daily Brief/FAILED-${TODAY}.md"
notify_fail() {
  local reason="$1"
  mkdir -p "$HOME/Desktop/Daily Brief" 2>/dev/null
  { echo "# Daily Brief 生成失敗 — $(date '+%F %T')"; echo ""
    echo "- 理由: ${reason}"
    echo "- 詳細ログ: ~/.claude/logs/vault-auto-ingest.log"
    echo "- 自動再試行: 8:20 / 10:45 / 12:15 とログイン時(成功したらこのファイルは自動で消える)"
  } > "$FAILED_FILE" 2>/dev/null
  /usr/bin/osascript -e "display notification \"${reason}\" with title \"Daily Brief 生成失敗\"" >/dev/null 2>&1
}

「成功したらこのファイルは自動で消える」 点が設計の肝です。FAILED_FILE は成功時のアーカイブ処理(step2.6)でスクリプトが自力で削除します。つまり「ファイルが存在している間=失敗中」です。デスクトップを開いたときにファイルがあれば、今日はまだ失敗中だとわかります。ファイルがなければ成功済み。チェックが視覚的に自明です。

START_STAMP による「今日の成果」鮮度判定

step2.5 でブリーフを生成した後、それを Desktop にアーカイブする処理があります。そこに鮮度チェックが入っています。

START_STAMP=$(mktemp /tmp/vault-ingest-start.XXXXXX)
# ... step2.5 でブリーフ生成 ...
if [ -s "$BRIEF_SRC" ] && [ "$BRIEF_SRC" -nt "$START_STAMP" ]; then
  # アーカイブ処理
  touch "$DONE_MARKER"
else
  notify_fail "ブリーフ生成が未完(claude 失敗/timeout の可能性)— 次スロットで自動再試行"
fi

スクリプト開始時点の空ファイル START_STAMP を作り、ブリーフの mtime がそれより新しいときだけ「今日の成果」と認めます。これがないと、昨日 step2.5 が成功して残った today-brief.md を「今日のブリーフが生成された」と誤解して DONE_MARKER を立ててしまいます。結果として「今日の claude が何も書いていない昨日の文書が、今日のブリーフとして Desktop にコピーされる」事故が起きます。実際に一度やらかしました。

ログのローテートと残骸掃除

claude -p の出力はすべて vault-auto-ingest.log に追記されるため、活発な時期には数日で数十 MB に膨らみます。現在このファイルは 4,884 KB です。

if [ -f "$LOG" ] && [ "$(stat -f%z "$LOG" 2>/dev/null || echo 0)" -gt 5242880 ]; then
  mv "$LOG" "${LOG}.old"
fi

5 MB(5,242,880 バイト)を超えたら .old にリネームします。古い .old は次のローテートで上書きされます。2 世代保持という運用です。

後片付けのもう一つが、古い FAILED マーカーの掃除です。

find "$HOME/Desktop/Daily Brief" -maxdepth 1 -name "FAILED-*.md" \
  ! -name "FAILED-${TODAY}.md" -delete 2>/dev/null

この1行が入っていない初期バージョンでは、連続障害のあとにデスクトップへ過去日付の FAILED ファイルが溜まり続けました。正常稼働日に走っても当日分しか消さなかったため、FAILED-20260611.md FAILED-20260612.md FAILED-20260613.md が3日分並ぶ状態になりました。後述しますが、この事態が実際に起きています。


私が詰まった話

hot.md が3日間凍結した——根本原因の話

この環境を作って最初の「壁」は、step2 の 1 タスクに詰め込みすぎという設計ミスでした。

当時の step2 は Claude ログと Codex ログを一括で処理する単一タスクで、timeout は 2400 秒(40 分)でした。スクリプトのコメントに今も残っています。

# step2 は活動多発期に28h分のClaude+Codexログ消化+全記事リライトが40分枠に収まらず
# 連日timeout(hot.md凍結の真因, 2026-06-11〜13)

何が起きていたか。Claude Code と Codex を両方ヘビーに使う日が続くと、28 時間分の会話ログは合計で数百ファイルに達します。「直近 28 時間のログをすべて読んで、vault の全記事を更新しろ」という単一プロンプトは、要するに「184 ファイルを読んで、100 ページ以上のマークダウンを書き直せ」という指示になります。これが 40 分に収まるわけがありません。

4:55 のスロットが 40 分フルに走って timeout kill される。8:15 のスロットも同じ場所でフルに走って timeout kill される。10:15 も、12:15 も。4 スロット全部が同じ処理を完走できずに落ちました。その結果、hot.md はインジェストされず、3 日間古いまま凍結します。

修正の方針は「1 タスクを分割する」でした。Claude ログと Codex ログを別プロセスに分けて、それぞれに独立したマーカーを持たせる。マーカーがあれば「このソースは今日処理済み」としてスキップする。片方が 25 分で完走できなくても、次のスロットが残りだけを再試行できます。

分割後のタイムアウトは各 1500 秒(25 分)。1 ソース分のログ消化なら、活発な日でも 20 分前後で完了します。3 日間凍結した hot.md は、修正翌日から毎朝更新されるようになりました。

launchd から ~/Documents に書けなかった

環境を組み始めてすぐに踏んだのが macOS TCC(プライバシー保護)の壁です。2026-06-01 の話です。

pmset でMacが 4:55 に自動起床することは確認できていました。launchd ジョブも起動していました。ところが today-brief.md は更新されていない。vault への git commit もされていない。ログには何も残っていない。

切り分けを進めると、bash で cd ~/Documents/claude-obsidian は通る。ls も通る。しかし claude バイナリ・gitpython は、launchd 配下から呼ばれると ~/Documents への書き込みを黙って失敗します。macOS が「フルディスクアクセス(FDA)」を付与されていないバイナリから ~/Documents ~/Desktop ~/Downloads への書き込みを TCC 層でブロックするためです。エラーコードも返さない。exit 0 で静かに死ぬ。

解決策の候補は2つありました。「システム設定でフルディスクアクセスに /bin/bash を追加する」か、「vault の実体を ~/Documents の外へ移す」かです。FDA を付与する方法は、claude のバイナリが更新されるたびにまた設定が必要になります(バイナリ単位の確認のため)。vault を保護外に移す方法は、一度やれば claude が更新されても壊れません。

一度移設したとき、次の罠として ~/ への symlink を試みました。しかし iCloud が symlink を競合処理して壊すため、この方法は使えないことが 2026-06-01 の実測で確認されています。スクリプトのヘッダーにそのまま残しています。

# ※ symlinkで ~/ に逃がす案は iCloud が symlink を競合処理して壊すため不可(2026-06-01 検証済み)

現在は TCC プリフライトを入れているので、FDA 未付与の日は「FDA が付いていない」と大声でログに残して止まります。少なくともサイレント失敗は消えました。

caffeinate -s がバッテリー駆動で沈黙する

「蓋を閉じても処理が続く」はずが、バッテリー駆動のときに凍結する事象が繰り返し起きました。スクリプトのコメントに理由が残っています。

# caffeinate -s はバッテリー駆動だと無効=蓋閉じスリープで凍結する。凍結したランは
# wake後に timeout が刈り、次スロットがやり直す(step2 は半マーカーでスキップ)。

caffeinate -s はシステムスリープを防ぐオプションですが、AC 電源接続時にのみ有効です。バッテリー駆動のとき、蓋を閉じると Mac はスリープします。スリープ中は claude -p のネットワーク接続が切れ、処理が止まります。その後 timeout --kill-after=30 が刈り取るまで、ゾンビ状態で残ります。

-i オプション(ディスプレイスリープの防止)も一緒に渡していますが、これはシステムスリープには効きません。組み合わせても、バッテリー駆動での蓋閉じスリープは防げません。

この問題に対して「バッテリー駆動でも凍結させない」技術的解決を探しましたが、macOS の電源管理の制約上、無人での回避は困難です。現実的な対策として「凍結してもキャッチアップスロットが処理する」設計にしています。step2a や step2b のサブマーカーがあるおかげで、凍結で中断しても次のスロットが「完了済みのサブをスキップして、残りだけ再試行する」動きをします。完走できなかった場合の損失が、1 ソース分のログ処理時間に限定されます。

朝起床したら充電器を繋ぐ習慣をつけることで、4:55 のメインスロットが AC 接続で動く確率は上がります。しかし深夜に充電器を外して使っていた場合は、翌朝のキャッチアップスロットで補完されます。

2026-06-13——全スロット完全ハングの日

これが最も印象的な障害です。6 月 13 日、4 スロット全部が同じ場所でフルにハングして timeout kill されました。

04:55:00 開始 → 05:40:01 step2 timeout
08:15:xx 開始 → 09:40:xx step2 timeout
10:15:xx 開始 → 11:40:xx step2 timeout
12:23:xx 開始 → 13:40:xx step2 timeout

ログのパターンが完全に一定でした。claude -p --dangerously-skip-permissions が 2400 秒フルに待ってから timeout kill される。4 スロット、4 連続。前日の 6 月 12 日は正常でした。翌日の 6 月 14 日も正常でした。

原因は claude 側の一過性障害でした。レート制限か認証の問題かネットワークの瞬断か、翌日には解消していたため特定はできていません。自動化が完璧に動き続けることは約束できませんが、「黙って欠けることはない」状態にはできます。この日は FAILED ファイルがデスクトップに作られ、システム通知も飛んでいました。

もう一つ後始末の問題がありました。当時の掃除コードは「当日成功時に当日分の FAILED ファイルだけを消す」設計で、過去日付の FAILED ファイルには手を付けませんでした。14 日に復旧したとき、デスクトップに FAILED-20260611.mdFAILED-20260612.mdFAILED-20260613.md の 3 ファイルが並んでいました。片付けるには手動で削除するしかありませんでした。

この経験から、正常稼働日に走ったとき「過去日付の FAILED ファイルをまとめて掃除する」1 行を追加しました。

find "$HOME/Desktop/Daily Brief" -maxdepth 1 -name "FAILED-*.md" \
  ! -name "FAILED-${TODAY}.md" -delete 2>/dev/null

以来、多日連続で障害が続いても、復旧した日の朝には過去の残骸が自動で消えます。デスクトップがクリーンなら「今は正常」、FAILED ファイルがあれば「今日はまだ失敗中」——この判断が視覚的に保たれます。


障害を経るたびにスクリプトは1行ずつ堅くなっています。今の形は「最初から設計した」ものではなく、実際に詰まった順に積み上がった証拠の集合体です。自動化は作って終わりではなく、動かし続けて初めて形が定まります。次回は、ここまでの仕組みが実際にどう機能しているか——具体的な数字で見ていきます。

前回は「hot.md 3日凍結」「TCC 無音死」「caffeinate -s バッテリー無効」「2026-06-13 全スロット完全ハング」の4件を取り上げました。スクリプトが成熟するにつれ、詰まりの種類も増えていきます。前回お伝えしきれなかった追加の詰まりポイントを列挙します。

つまずきポイント(続き・追加9件)

① iCloud が symlink を壊す

TCC 問題を解決するとき、最初に試みたのは「保護外ディレクトリに vault の実体を置き、~/Documents から symlink を張る」案でした。2026-06-01 の実測で、iCloud が symlink を競合処理して壊すことを確認し、即座に断念しています。その記録がスクリプトのヘッダーにそのまま残っています。

# ※ symlinkで ~/ に逃がす案は iCloud が symlink を競合処理して壊すため不可(2026-06-01 検証済み)

iCloud 同期フォルダへの symlink 設置は、計画段階で選択肢から除外する必要があります。

② Node の絶対パス固定が Node 更新で一斉崩壊する

wiki の B+ 監査記録に「v24.13.0 固定参照が現在5ファイル、Node 更新で破損リスク」と残っています。バージョンを直書きしたスクリプトは Node のアップグレード直後に全滅します。現在の vault-auto-ingest.sh はこの教訓を受けて、バージョンを固定せず動的に解決しています。

NODE_BIN=$(ls -d "$HOME"/.nvm/versions/node/*/bin 2>/dev/null | sort -V | tail -1)

sort -V(バージョンソート)の末尾を取ることで、将来の Node アップグレードに自動追従します。

③ launchd の codesigning spawn 失敗(argv[0] に Homebrew バイナリ)

ProgramArguments の先頭要素に /opt/homebrew/bin/timeout を直接置いた plist が、codesigning spawn 失敗で完全に無音で死ぬ事象が発生しました。launchd は argv[0] のバイナリを adhoc 署名で検証します。Homebrew でインストールした timeout が通らず、ジョブが起動した形跡すらログに残りません。self-repair はこれを「healthy」と誤宣言していました。

解決策は ProgramArguments/bin/bash -c "exec /opt/homebrew/bin/timeout ..." のラッパー構成にすることです。/bin/bash は macOS 標準バイナリのため署名を通過します。同型の plist が7本あり、一括でラッパー化しています。

④ 日本語パスの NFD/NFC 文字化けで launchd が起動しない

ProgramArguments に日本語ディレクトリを直書きした plist が can't open input file で起動失敗しました。macOS のファイルシステムは NFD(分解形)で文字を保持しますが、plist に焼いた文字列が NFC(合成形)だとバイト列が一致しません。同じ「新アカ」という表記でも、plist とファイルシステムとで正規化方式が食い違うと開けなくなります。

対策は ASCII パスのラッパースクリプトから glob で実体を引くことです。

TARGET=($HOME/Desktop/SugarLAB_*/run_daily.sh)
[ -f "${TARGET[0]}" ] && exec /bin/bash "${TARGET[0]}"

plist の ProgramArguments はこのラッパーのみを指します。日本語ディレクトリ名が変わっても glob が吸収します。

⑤ iCloud 上のファイルを jq に直接 open させると EINTR で確率的に失敗する

iCloud 同期フォルダ上のファイルを jq '.slug' "$FILE" と引数で渡すと、open() のタイミングで EINTR(Interrupted system call)を確率的に踏みます。slug が空になり、その日の記事生成が丸ごと飛ぶ事故が発生しました。修正は1文字の変更です。

# 変更前(EINTR で確率的に失敗)
jq '.slug' "$ICLOUD_FILE"

# 変更後(stdin 経由。jq 自身に open() させない)
jq '.slug' < "$ICLOUD_FILE"

iCloud 上のファイルを扱う全コマンドに横展開する原則として定着しています。

⑥ git commit-msg フックが真犯人に見える赤ニシン

2026-06-13 の全スロットハング(前回記事参照)のログに「commit-msg フック / node 依存を疑え」というエラーが残っていました。実際の commit-msg フックは純 bash の conventional-commits 正規表現で、Node 非依存です。chore(vault): は問題なく通過します。真因は commit に到達するより前の step2 が死んでいたことで、フックは完全に無関係でした。エラーメッセージを鵜呑みにする前に、claude -p "say OK" で claude バイナリの生死を実際に確認することで無駄な調査を30分以上省けます。

⑦ Desktop を実行ディレクトリに使う → iCloud が削除する

実行スクリプトを ~/Desktop/ 配下に置いていたところ、iCloud Drive の同期挙動でファイルが消えました。Desktop は iCloud Drive の管理下にあり、他デバイスとの競合解消でファイルが削除・上書きされます。実行コードの実体は ~/dev/ に置き、Desktop からは参照のみとする構成に変更しました。この教訓が ~/FILEMAP.md の「dev = 動くもの」原則として明文化されています。

⑧ LLM が更新した「総ページ数」が実数とズレる

claude -pwiki/index.md を更新するとき、「総ページ数」はLLM出力です。実際のファイル数と食い違うことが繰り返し発生しました。現在は claude -p の実行直後、git commit の前に決定論的な再集計を挿入しています。

real=$(find "$VAULT/wiki" -name '*.md' -not -path '*/.*' | wc -l | tr -d ' ')
sed -i '' -E "s/総ページ数:[0-9]+/総ページ数:${real}/" "$INDEX_FILE"

LLM 出力の数値は後段の決定論処理で上書きする——これはページ数に限らない設計原則です。

⑨ 自己修復がスコープ外の本番資産を破壊する

2026-06-22、affiliate-factory.env 消滅・post-to-hatena.sh 破損・launchd plist の XML 破損・Desktop 作業ディレクトリの消滅が同時発生しました。自己修復ウォッチドッグが混線出力によってファイルを上書き・削除した疑いが濃い事例です。canary テスト環境で成立していたスコープ制限が、実運用ジョブに効いていませんでした。.env のような gitignore で復元不可なファイルは自己修復の射程から明示的に外す必要があります。wiki にはその後の方針として「秘密値・『消すと壊れる』作業 dir は自己修復の射程外に置く。冪等でない修復は最重要資産を消す」と残っています。


ベストプラクティス

実障害から引き出した実装指針を、コードと数字で整理します。

1. 1 タスク 1 マーカー——タイムアウト境界を細かく切る

最大の教訓です。「Claude ログ + Codex ログを28時間分消化」という1タスクは2400秒(40分)枠に収まらず、hot.md を3日間凍結させました。スクリプトのコメントに当時の痕跡が残っています。

# step2 は活動多発期に28h分のClaude+Codexログ消化+全記事リライトが40分枠に収まらず
# 連日timeout(hot.md凍結の真因, 2026-06-11〜13)。

分割後の各サブは1500秒(25分)。活発な日でも20分前後で完了します。1つの claude -p が完走できる粒度に切ることが、自己回復設計の前提条件です。

2. caffeinate self-invoke + CAFFEINATED 環境変数で無限再帰を防ぐ

if [ -z "${CAFFEINATED:-}" ]; then
  exec /usr/bin/caffeinate -i -s env CAFFEINATED=1 /bin/bash "$0" "$@"
fi

exec でプロセスを置き換えているため「caffeinate → bash → caffeinate → …」の無限再帰になりません。CAFFEINATED を環境変数として子プロセスへ引き継ぐことが二重起動防止の鍵です。

3. mkdir アトミックロック + kill -0 で stale を自動回収する

mkdir 自体がアトミック操作なのでレースコンディションが発生しません。stale ロック(プロセスが死んでロックだけ残った状態)は kill -0 $pid でプロセスの生死を確認してから自動回収します。2026-06-10 に launchd 分と手動分のレースが実発生して以来、全ジョブに適用しています。

4. TCC プリフライトで「大声の失敗」を作る

macOS の TCC は launchd 配下から ~/Documents への書き込みを黙って失敗させます。exit 0 で静かに死ぬため、ログに何も残りません。git rev-parse --git-dir を処理開始前に挿入することで、FDA が付いていない日は最初から大声でログに残して止まります。サイレント失敗と大声の失敗は根本的に違います。後者は翌朝に即座に原因を特定できます。

5. FAILED ファイル「存在 = 失敗中」設計——成功時に自動削除する

notify_fail() {
  { echo "# Daily Brief 生成失敗 — $(date '+%F %T')"
    echo "- 自動再試行: 8:20 / 10:45 / 12:15(成功したらこのファイルは自動で消える)"
  } > "$FAILED_FILE"
}

FAILED ファイルは成功時にスクリプトが自力で削除します。デスクトップにファイルが存在する間は失敗中、なければ成功済みです。さらに過去日付の FAILED ファイルも正常稼働日に自動掃除します。

find "$HOME/Desktop/Daily Brief" -maxdepth 1 -name "FAILED-*.md" \
  ! -name "FAILED-${TODAY}.md" -delete 2>/dev/null

6. START_STAMP 鮮度ゲート——昨日の成果を今日の成果と誤判定しない

スクリプト開始時点に空ファイルを mktemp で作り、ブリーフの mtime がそれより新しいときだけ「今日の成果」と認めます。

START_STAMP=$(mktemp /tmp/vault-ingest-start.XXXXXX)
# ... claude -p 実行 ...
[ -s "$BRIEF_SRC" ] && [ "$BRIEF_SRC" -nt "$START_STAMP" ] && touch "$DONE_MARKER"

このゲートがない初期バージョンでは、前日生成済みの today-brief.md を「今日のブリーフが生成された」と誤判定して DONE_MARKER が立つ事故を一度やらかしています。

7. Node パスはバージョン非依存で動的解決する

sort -V | tail -1 でインストール済みの最新バージョンを自動選択します。v24.13.0 のような固定パスはアップグレードの瞬間に全スクリプトを壊します。実際に過去5ファイルに固定パスが残っており、Node 更新のたびに手動修正が必要でした。

8. 90 秒ネット待ちループは「即 exit」せず続行する

4:55 でネット未接続のまま続行して失敗しても、8:20 のキャッチアップスロットがリトライします。「接続できなければ即 exit 1」にすると、繋がっている環境で再試行できるのに最初から止まります。失敗コストは「このスロットの処理が飛ぶ」だけで、次のスロットが補完します。

9. マーカーは 7 日で自動掃除する

find "$HOME/.claude/logs" -maxdepth 1 -name '.vault-ingest-*' -mtime +7 -delete 2>/dev/null

この1行がなければマーカーが無限に蓄積します。過去7日分だけが残り、それより古い完了の証拠は自動で消えます。ls ~/.claude/logs/.vault-ingest-step2* で今日の状態を即座に確認できます。

10. launchd の ProgramArguments に日本語パスを直書きしない

NFD/NFC の正規化ズレで can't open input file になります。回避策は ASCII パスのラッパースクリプトから glob で実体を引くことです。

TARGET=($HOME/Desktop/SugarLAB_*/run_daily.sh)
[ -f "${TARGET[0]}" ] && exec /bin/bash "${TARGET[0]}"

11. 自己修復のスコープを ~/.claude/ 内に限定する

修復理由がスコープ外なら UNFIXABLE: <理由> を宣言して終了します。推測でスコープ外のファイルを書き換えると、2026-06-22 の affiliate-factory 事故のように .env 消滅・launchd plist 破損が同時発生します。自己修復は「直せる範囲だけ直す」という原則が最重要です。

12. jq に iCloud ファイルを直接 open させない

iCloud 同期フォルダ上のファイルは必ず < "$file" で stdin 経由で渡します。jq '.key' "$file" と引数指定すると EINTR を確率的に踏みます。

13. plutil -lint + .bak + bootout/bootstrap の三点セット

plist を編集するたびに:

  1. 変更前を .bak で退避
  2. plutil -lint で構文確認(NG なら即 .bak を戻す)
  3. launchctl bootout gui/$UID/$LABELlaunchctl bootstrap gui/$UID /path/to.plist で再ロード

bootout を忘れると古い plist が残ったまま新しい plist も起動し、二重実行が始まります。wiki には「launchd のジョブが6週間二重実行されていた」記録が残っています。

14. LLM 出力の数値は後段の決定論処理で上書きする

claude -p が出力したページ数・ファイル数は LLM 生成値です。正確さが必要な数値は後段で実測を差し込みます。

real=$(find "$VAULT/wiki" -name '*.md' -not -path '*/.*' | wc -l | tr -d ' ')
sed -i '' -E "s/総ページ数:[0-9]+/総ページ数:${real}/" "$INDEX_FILE"

「LLM が言った数字」と「find | wc -l で数えた数字」を混在させないことが信頼性の土台です。

15. 「完璧に毎日」は約束できない。「黙って欠けることはない」は設計できる

4スロット発火 + マーカー冪等 + FAILED 通知 + git 安全網の組み合わせが保証するのは、「完璧に動き続けること」ではありません。障害が起きても必ず気づき、次の機会に回復することです。実際に ls ~/.claude/logs/.vault-ingest-step2* を叩くと、07-10 だけ step2b-codex マーカーが欠落しています。それでも翌日の07-11 には3本すべてが揃い直しています。1日の欠落を許容したうえで自己回復する設計が、運用コストゼロの自動化を実現します。


まとめ

マーカーの現状です。

$ ls ~/.claude/logs/.vault-ingest-step2*

~/.claude/logs/.vault-ingest-step2-done-20260711
~/.claude/logs/.vault-ingest-step2-done-20260712
~/.claude/logs/.vault-ingest-step2-done-20260713
~/.claude/logs/.vault-ingest-step2-done-20260714
~/.claude/logs/.vault-ingest-step2a-claude-20260711
~/.claude/logs/.vault-ingest-step2a-claude-20260712
~/.claude/logs/.vault-ingest-step2a-claude-20260713
~/.claude/logs/.vault-ingest-step2a-claude-20260714
~/.claude/logs/.vault-ingest-step2b-codex-20260709
~/.claude/logs/.vault-ingest-step2b-codex-20260711
~/.claude/logs/.vault-ingest-step2b-codex-20260712
~/.claude/logs/.vault-ingest-step2b-codex-20260713
~/.claude/logs/.vault-ingest-step2b-codex-20260714

07-11 から07-14まで4日連続で step2a-claudestep2b-codexstep2-done の3本が揃っています。07-10 は step2b-codex だけ欠落しています。何らかの理由で Codex ログ側が完走できなかった日ですが、翌日には自力で回復しています。1ファイルの有無が「このソースは今日処理済みか」を過不足なく表します。

vault-auto-ingest.sh は現在261行です。ほとんどの行に障害の記憶があります。set -u 直後の PATH 補填は launchd 最小環境への対応、caffeinate の self-invoke は バッテリー駆動での凍結対策、90秒ネット待ちループは wake 直後の WiFi 未接続対策、TCC プリフライトは保護領域への無音死の対策です。コードの密度そのものが、運用を経た証拠の集積です。

「毎朝 Obsidian に昨日の Claude とのやりとりが整理されている」状態は、1夜のハッキングで作れるものではありません。3日間凍結し、TCC に黙って殺され、全スロットがフルハングし、.env を自己修復が消した経験が、今の設計を形にしました。自動化は障害のたびに1行ずつ堅くなります。この積み上げそのものが、月商120万を支える「環境が自分より先に動いている」感覚の正体です。


仕組みの全体像・月120万の内訳・30日手順は有料noteにまとめています。
📕 Claude Code自律環境で、実際どう稼ぐか ― 仕組み・実例・始め方・サポート


Lily@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています

皆さんの ❤️ やシェアが励みになります!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?