Last time I wrote about a Terminal-watching daemon that auto-clicks Computer Use approval dialogs. This post is the same genre—an unattended job dying quietly—but the culprit wasn't a gap in monitoring. It was the design itself. founder-scout's automated DM lane sent zero messages for 13 days because of a single bad record, and nobody noticed.
The problem: the exit code was there, but nobody saw it
founder-scout has a followers lane that sends a first DM to new followers of @bokuwalily. When I checked on 2026-09-10, I found that the last successful send was 8/28 at 16:35. Thirteen days, not a single DM.
The queue (state/followers_queue.jsonl) kept growing with every run, from 336 entries to 390. In other words, drafts were being generated daily and the send script was launching daily. Still zero sends. The logs recorded process.exit(3) day after day, but that abort path was never wired to a Discord alert, so it never crossed anyone's eyes.
Root cause: turning "one bad record" into "everything stops"
On 8/28 I wired a number-gate (checkColdOutbound) into all seven outbound paths to stop drafts from leaking my actual download counts or revenue figures. At that time I designed followers_send.mjs and dm.mjs so that if even one draft in the batch leaked a number, the script would immediately process.exit(3) and halt everything.
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 every run picked the same six entries from the head of the queue, and every run stopped on the same one. The sendable drafts behind it were never evaluated once—they just kept piling up in the queue.
Note
The same day, a different incident of the same shape happened on thedm.mjsside. 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: the head of the queue gets pinned → the job stops there every time.
Fail-closed is for stopping that one record
This is the lesson. Pre-send checks like the number-gate or composer validation exist to keep one dangerous message from going out. But if you implement them as "any anomaly → exit the whole thing," then unless that anomaly heals itself, the entire lane stops permanently. An unrecorded anomaly stays parked at the head of the queue, so it never heals itself.
The fix has two parts.
1. Record the dropped row in the ledger and treat it as exhausted
Here's the relevant section of 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) is gone. Instead, it writes ok:false, reason:'number-gate: …' to the SENT ledger, marks only that one entry as exhausted, and the rest proceed to the batch as usual. dm.mjs got exactly the same treatment.
// 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));
Because it's now in the ledger, from the next run onward this entry is excluded at the point where the exhausted set is built (followers_send.mjs:49-51 and dm.mjs:50-52, where the string number-gate is picked out of reason and added to exhausted). The moment it was recorded, this anomaly became one that "automatically disappears on the next run."
2. Move anyone with a failure history to the back of the queue
The other fix is a reorder so that the three-consecutive-failures guard (break when consecutiveFailures >= 3) doesn't keep hitting the same faces every time.
// 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));
It just sorts by attempts (the number of past send attempts) in ascending order. Now people who are structurally destined to keep failing—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.
Monitoring: an exit code alone doesn't mean "it's working"
The reason this went unnoticed for 13 days is that the logs from the broken abort path only lived locally. Seeing "loaded" in launchctl list or pm2 list only means the job is executing; whether it's producing results is a separate question.
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 laneToday (the lane's send count for the day) and added an operational rule: three consecutive days at 0 is an anomaly. The log line at followers_send.mjs:101 is what feeds that.
console.log(`queue=${sendable.length} laneToday=${laneToday}/${perDay} accountToday=${sentToday.length}/${cfg.limits.dmPerDay} thisRun=${batch.length} held=${held}`);
Pitfalls I hit
-
Designing
process.exit(3)on a single anomaly → an unrecorded anomaly stays parked at the head of the queue and the lane stops permanently - Forgetting to wire a Discord alert to the abort path → an exit code in a log nobody reads is the same as silence
- Skipping the dropped row without writing it to the ledger → the next run re-evaluates the same draft in the same position and gets stuck on the same one every time
- The three-consecutive-failures guard hitting the same pinned people every run → simply moving anyone with a failure history to the back keeps the sendable people behind them from being taken down with them
-
Judging "running" from the loaded state in
launchctl listalone → it's only healthy once you've checked the exit codes and the real counts on the platform side
Takeaways
- A fail-closed gate is written to stop that one record, not to stop the lane
- Halt-everything plus not-recorded equals permanent stop unless it clears on its own next run. Whenever you write a halt, ask: "Will this stop condition naturally disappear on the next run?"
- The fix was a two-line prescription: write the dropped row to the ledger with
ok:false, reasonand mark it exhausted, and sort anyone with a failure history to the back of the queue by ascending attempts - The loaded state in
launchctl listor anexit 0isn't enough for monitoring. Only when you've looked at the distribution of exit codes plus the real counts on the platform can you say "it's working"
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?
Written by **Lily* — I ship iOS apps and automate my content stack with Claude Code.
Follow along: Portfolio · X · GitHub*
Top comments (0)