前回、Computer Use承認ダイアログを自動で押すTerminal監視デーモンの話を書きました。今回は同じ「無人ジョブが静かに死ぬ」系ですが、原因は監視の穴ではなく設計そのものでした。founder-scoutのDM自動送信レーンが、1件の異常データのせいで13日間0通のまま気づかれなかった話です。
困りごと:exit codeは出ていたのに誰も気づかなかった
founder-scoutには、@bokuwalilyのフォロワーへ初回DMを送るfollowersレーンがあります。2026-09-10に確認したところ、直近の送信が8/28 16:35で止まっていたことが分かりました。13日間、1通も送れていませんでした。
キュー(state/followers_queue.jsonl)は run のたびに積み上がり続けていて、336件から390件まで増えていました。つまり下書き自体は毎日生成されていて、送信スクリプトも毎日起動していた。それでも0通。ログにはprocess.exit(3)が連日記録されていましたが、そのabort経路がDiscordアラートを呼ばない実装だったため、誰の目にも留まりませんでした。
真因:「1件アウト」を「全体アウト」にしていた
8/28に、下書きへ自分のダウンロード数や売上の実数が紛れ込むのを防ぐnumber-gate(checkColdOutbound)を送信7経路に配線しました。このときfollowers_send.mjsとdm.mjsを、バッチの中に1件でも数字漏れがあればその場でprocess.exit(3)して全体を止める設計にしていました。
漏れていた下書きは@kouAI_work宛ての「ダウンロードが8月25…」という文面でした。この1件は台帳に何も記録されなかったので、毎run同じキュー先頭の6件が選ばれ、毎run同じこの1件で止まる。後ろに控えていた送信可能な下書きは、一度も評価されることなくキューに積み上がり続けました。
同じ日、dm.mjs側でも同型の別の事故が起きていました。同じ3人へのcomposer timeoutが3連続失敗になり停止判定に当たった結果、後ろの4人が2run連続で送れていませんでした。原因は違えど、「キュー先頭が固定される→そこで毎回止まる」という同じ形をしています。
fail-closedは「その1件を止める」ためのもの
ここが今回の教訓です。number-gateやcomposer検証のような送信前チェックは、危険な1通を出さないために存在します。しかし「1件でも異常があれば全体をexitする」という実装にすると、その異常が自己修復しない限りレーン全体が永久停止します。記録されない異常はキュー先頭に居座り続けるので、自己修復するはずもありません。
修正は2点です。
1. 落とした行は台帳に記録してexhausted扱いにする
src/followers_send.mjsの該当箇所です。
// src/followers_send.mjs:84-97
// 送信前ゲート: 自分のDL数・売上の実数が入っている下書きは「その1件だけ」外して台帳に記録する。
// 1件で全体を止めると、その下書きがキュー先頭に残り続けてレーンごと止まる
// (実測2026-08-28〜09-10: kouAI_work 1件で13日間0通)。
{
const gateAt = new Date().toISOString();
const leaks = drafts
.map((t) => ({ who: t.handle || '?', bad: checkColdOutbound(t.dm || '') }))
.filter((x) => x.bad.length);
for (const l of leaks) {
console.error(`number gate: 除外 @${l.who} — ${l.bad.map((b) => b.why).join('/')}`);
appendSync(SENT, { handle: l.who, ok: false, reason: 'number-gate: ' + l.bad.map((b) => `${b.why}(${b.hit})`).join('/'), follow: 'skipped', at: gateAt, lane: LANE });
exhausted.add(l.who);
}
}
const sendable = drafts.filter((d) => !exhausted.has(d.handle));
const batch = sendable.slice(0, Math.min(perRun, laneLeft, accountLeft));
process.exit(3)は消えました。代わりにSENT台帳へok:false, reason:'number-gate: …'を書き込み、その1件だけをexhausted扱いにして、残りは通常どおりバッチへ進みます。dm.mjs側もまったく同じ形で入っています。
// src/dm.mjs:82-94
// 送信前ゲート: 数字漏れの下書きは「その1件だけ」外して台帳に記録する。全体を止めると
// 先頭に残り続けてレーンごと止まる(followers レーンで13日間0通の実測あり)。
{
const gateAt = new Date().toISOString();
const leaks = queue
.map((t) => ({ who: t.handle || '?', bad: checkColdOutbound(t.dm || '') }))
.filter((x) => x.bad.length);
for (const l of leaks) {
console.error(`number gate: 除外 @${l.who} — ${l.bad.map((b) => b.why).join('/')}`);
recordSent({ handle: l.who, ok: false, reason: 'number-gate: ' + l.bad.map((b) => `${b.why}(${b.hit})`).join('/'), follow: 'skipped', at: gateAt });
exhausted.add(l.who);
}
}
const sendable = queue.filter((r) => !exhausted.has(r.handle));
台帳に書いたことで、次run以降はexhausted集合の構築時点でこの1件が最初から除外されます(followers_send.mjs:49-51、dm.mjs:50-52でnumber-gateという文字列をreasonから拾ってexhaustedに足している箇所)。記録した瞬間に、この異常は「次runで自動的に消える」異常になったわけです。
2. 失敗歴のある相手はキューの後ろへ
もう一つの修正が、3連続失敗ガード(consecutiveFailures >= 3でbreak)がいつも同じ顔ぶれに当たらないようにする並べ替えです。
// src/followers_send.mjs:81-83
// 一度失敗した相手は後ろへ回す。先頭の同じ数人が毎run失敗すると「3連続失敗で停止」に
// 当たり、後ろの送れる人まで道連れになる(実測2026-09-10: 同じ3人で2run連続0通)。
.sort((a, b) => (attempts.get(a.handle) || 0) - (attempts.get(b.handle) || 0));
attempts(過去の送信試行回数)が少ない順にソートするだけです。これで、DMが開放されていない・composerがタイムアウトするといった構造的に失敗し続ける相手がキュー先頭を占有しなくなり、後ろに並ぶ送信可能な相手が毎run道連れで止まることもなくなります。
監視:exit codeが出ているだけでは「動いている」とは言えない
この事故がなぜ13日も気づかれなかったかというと、壊れたabort経路のログはローカルに残っていたからです。launchctl listやpm2 listに「ロードされている」と出ていても、それは実行はされているという意味でしかなく、成果を出しているかどうかは別問題です。
判定は3点セットで見る必要があります。①ジョブがロードされているか、②直近ログのexit codeの分布、③媒体側の実物(この場合はDM送信済みの実件数)。今回のケースは①②までは正常に見え、③を見て初めて「13日間0通」に気づきました。加えて、laneToday(当日のレーン送信数)をログに毎run出力するようにし、3日連続で0になったら異常という閾値を運用ルールに足しました。followers_send.mjs:101のログ行がその材料です。
console.log(`queue=${sendable.length} laneToday=${laneToday}/${perDay} accountToday=${sentToday.length}/${cfg.limits.dmPerDay} thisRun=${batch.length} held=${held}`);
踏んだ落とし穴
-
1件の異常で
process.exit(3)する設計 → 記録されない異常はキュー先頭に居座り続け、レーンが永久停止する - abort経路にDiscordアラートを配線し忘れた → exit codeがログに出ていても誰も見ない経路は「無音」と同じ
- 落とした行を台帳に書かずにスキップだけしていた → 次runで同じ下書きが同じ順番で再評価され、毎回同じ1件で詰まる
- 3連続失敗ガードが先頭固定の相手に毎回当たる → 失敗歴のある相手を後ろへ回すだけで、後続の送信可能な相手が道連れにならなくなる
-
launchctl listのロード状態だけで「稼働中」と判断していた → exit codeと媒体側の実件数まで見て初めて健康と言える
まとめ
- fail-closedなゲートは「その1件を止める」ために書くもので、「レーンを止める」ために書くものではない
- 全体停止+未記録の組み合わせは、次runで自動的に消えない限り永久停止になる。止める設計を書くときは必ず「この停止原因は次runで自然に消えるか」を自問する
- 修正は2行で済む処方箋だった。落とした行を台帳に
ok:false, reasonで記録してexhausted化し、失敗歴のある相手はattempts昇順でキュー末尾へ - 監視は
launchctl listのロード状態やexit 0だけでは不十分。exit codeの分布+媒体側の実件数まで見て初めて「動いている」と言える
Lily(@bokuwalily)― 個人開発者。Claude Code で自動化基盤を組みながら、iOSアプリやWebサービスを量産しています
- 制作物・記事は bokuwalily.com にまとめています🖥️
- AIで「寝てても回る仕組み」を作って月120万にした話は noteの有料記事 に💰
- OSS: github.com/bokuwalily 🐙
- 最新情報・お問い合わせは X @bokuwalily へ🌍
- AI導入・自動化の相談と実装テンプレ7本の配布は 公式LINE から💬
皆さんの ❤️ やシェアが励みになります!