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?

『1件でも異常なら全体停止』が13日間の無音死を作った話

0
Posted at

前回、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.mjsdm.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-51dm.mjs:50-52number-gateという文字列をreasonから拾ってexhaustedに足している箇所)。記録した瞬間に、この異常は「次runで自動的に消える」異常になったわけです。

2. 失敗歴のある相手はキューの後ろへ

もう一つの修正が、3連続失敗ガード(consecutiveFailures >= 3break)がいつも同じ顔ぶれに当たらないようにする並べ替えです。

// 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 listpm2 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サービスを量産しています

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

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?