cd /news/ai-agents/3-duplicate-instagram-posts-in-8-houโ€ฆ ยท home โ€บ topics โ€บ ai-agents โ€บ article
[ARTICLE ยท art-132276] src=dev.to โ†— pub= topic=ai-agents verified=true sentiment=ยท neutral

3 Duplicate Instagram Posts in 8 Hours: Anatomy of a False-Negative Success Check

A developer running an autonomous Claude Code environment of roughly 171 scheduled jobs traced three duplicate Instagram carousel posts on August 28, 2026 to a false-negative landing check: the posts succeeded, but the verification step returned ok:false, so the scheduler re-ran the job and republished identical content. The developer argues that for non-idempotent operations with side effects, a false negative is more dangerous than a false positive because it silently multiplies the side effect instead of halting the line.

by read29 min views4 publishedSep 17, 2026

I went from ยฅ100k/month as a student, to ยฅ600k juggling side gigs, to zero after a layoff, and then spent six months building an autonomous Claude Code environment that brought me back to ยฅ1.2M/month. What I learned along the way wasn't a string of automation wins. It was a pile of lessons about what happens when automation breaks.

On August 28, 2026, the same Instagram carousel showed up three times on my profile. Shortcodes DckjvpPoFRt, DckN-yYkIBsJ, and DclaPYooPjB โ€” posted at 13:58, 17:55, and 21:54 JST. All three times, the job logged "post not found on profile." All three times, a direct check against the Instagram API showed the post had gone through.

The operation succeeded, and the check called it a failure โ€” a false negative that repeated a side effect three times over.

"Building an environment" means driving the number of times I have to touch anything toward zero.

For Instagram, launchd fires a script on schedule, and that script runs caption generation, posting, landing verification, and ledger write-back end to end with no human in the loop. Around 171 jobs run side by side in total, and the Instagram carousel post is one lane. I never have to go check "did today's post go out?" โ€” the ok:true entry in the ledger and the Discord notification tell me instead.

The reason this is an "environment" rather than "work" is that the environment also decides when to retry.

If landing verification (_verify_landed()) returns ok:false, the same job runs again at the next scheduled slot. Instead of a human going "oh, that failed, let me try again," the scheduler re-runs it automatically. Zero time, zero effort โ€” as long as the verdict is correct.

When the verdict is wrong, the mechanism runs in reverse. If the post succeeded but ok:false keeps coming back, the scheduler interprets that as "still not posted" and sends the same content again. The side effect (the post) is already done, but the verification layer insists it isn't. Let retries run in that state and identical posts pile up without limit.

A false positive โ€” "it actually failed, but we called it a success" โ€” stops the shipping line. That's a visible failure. Someone notices and investigates.

A false negative โ€” "it actually succeeded, but we called it a failure" โ€” increases shipments. That's an invisible failure. The ledger fills with ok:false, and on the surface it looks like "posts are running late." Meanwhile the same post is being mass-produced, and the script calmly schedules the next retry.

For an operation with side effects, the damage from a false negative is not "a failed check" but "a duplicated side effect." Every wrong verdict leaves another trace in the real world. In Instagram's case, that trace was three identical carousels lined up on my profile.

My learning notes record it this way: "[[false-positive-stops-the-line]] was the 'a false alarm halts shipping' pattern. This one is a false alarm that increases shipping. Worse than stopping."

171 jobs run efficiently because each one can accurately judge whether its own work is done. If the verdict is accurate, a successful job runs once, and only failed jobs get re-executed.

When the verdict breaks, that premise collapses. Successful jobs get treated as failures and re-run too. If the side effect is idempotent (same result no matter how many times you do it), no problem โ€” but a social media post is not idempotent. Send once, one post goes public. Send three times, three go public.

Before asking "how many retries should this have?", you have to ask "is this operation idempotent?" Attaching three retries to a non-idempotent operation is a declaration that you'll tolerate the side effect up to three times. My retry-design reference (retry-and-giveup-design.md) says "throwing the same failure three times doesn't change the result," and it's the same point โ€” when the problem is in the input to the verdict itself, adding attempts changes nothing.

Here's the timeline of what happened on the day of the incident.

[launchd: 13:58 JST]
        โ”‚
        โ–ผ
  ig_autopost.py
        โ”‚
        โ”œโ”€ (1) ใ‚ญใƒฃใƒ—ใ‚ทใƒงใƒณ็”ŸๆˆใƒปๆŠ•็จฟๅฎŸ่กŒ
        โ”‚       IG ใ‚ซใƒซใƒผใ‚ปใƒซ้€ไฟก โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–บ ๆˆๅŠŸ โœ“ (DckjvpPoFRt)
        โ”‚
        โ””โ”€ (2) ็€ๅœฐ็ขบ่ช: _verify_landed()
                โ”‚
                โ”œโ”€ ใƒ—ใƒญใƒ•ใ‚ฃใƒผใƒซ DOM ๅ–ๅพ—
                โ”‚   ใ‚ญใƒฃใƒ—ใ‚ทใƒงใƒณๅ…ˆ้ ญ12ๆ–‡ๅญ—: "๐Ÿ‘พ ๅ€‹ไบบ้–‹็™บใฎ้‡็”ฃ..."
                โ”‚   IG ใƒฌใƒณใƒ€ใƒชใƒณใ‚ฐ:         <img alt="๐Ÿ‘พ"> โ† inner_text ใซๅ‡บใชใ„
                โ”‚   ็…งๅˆๆ–‡ๅญ—ๅˆ—(DOMๅด):        " ๅ€‹ไบบ้–‹็™บใฎ้‡็”ฃ v"  โ† ใ‚บใƒฌใ‚‹
                โ”‚
                โ””โ”€ ็…งๅˆNG โ†’ ok: false โ†’ ๅฐๅธณ: ใ€ŒๆŠ•็จฟใ•ใ‚Œใฆใ„ใชใ„ใ€
                        โ”‚
                        โ–ผ
[launchd: 17:55 JST] โ† ใ‚นใ‚ฑใ‚ธใƒฅใƒผใƒฉใ€Œใพใ ๆœชๆŠ•็จฟใ€ใจๅˆคๆ–ญใƒปๅ†ๅฎŸ่กŒ
        โ”‚
        โ”œโ”€ ๅŒใ˜ใ‚ญใƒฃใƒ—ใ‚ทใƒงใƒณใงๆŠ•็จฟ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–บ ้‡่ค‡1ๆœฌ็›ฎ (DckN-yYkIBsJ)
        โ””โ”€ _verify_landed() โ†’ ok: false โ† ใพใŸๅฝ้™ฐๆ€ง
                        โ”‚
                        โ–ผ
[launchd: 21:54 JST] โ† ใ•ใ‚‰ใซๅ†ๅฎŸ่กŒ
        โ”œโ”€ ๆŠ•็จฟ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–บ ้‡่ค‡2ๆœฌ็›ฎ (DclaPYooPjB)
        โ””โ”€ _verify_landed() โ†’ ok: true โ† ใ‚ˆใ†ใ‚„ใๆˆๅŠŸๅˆคๅฎš

Three posts in eight hours, all the same carousel. The script exited all three times believing it had "worked correctly." Only checking the IG API revealed the duplicates.

_verify_landed() fetched the profile page DOM and compared the first 12 characters of the body text with the first 12 characters of the caption. The design assumed "the DOM's inner_text contains the entire caption."

But Instagram renders emoji as <img> tags. When the caption starts with ๐Ÿ‘พ, the DOM contains <img alt="๐Ÿ‘พ">, and that character never appears in inner_text.

ใ‚ญใƒฃใƒ—ใ‚ทใƒงใƒณๆ–‡ๅญ—ๅˆ—: "๐Ÿ‘พ ๅ€‹ไบบ้–‹็™บใฎ้‡็”ฃ vol..."
็…งๅˆใ—ใ‚ˆใ†ใจใ—ใŸๆ–‡ๅญ—ๅˆ—(ๅ…ˆ้ ญ12ๆ–‡ๅญ—): "๐Ÿ‘พ ๅ€‹ไบบ้–‹็™บใฎ้‡็”ฃ vo"
DOM inner_text(ๅ…ˆ้ ญ12ๆ–‡ๅญ—):         " ๅ€‹ไบบ้–‹็™บใฎ้‡็”ฃ vol."  โ† ๐Ÿ‘พ ใŒ img ใซๅŒ–ใ‘ใฆๆถˆใˆใ‚‹

โ†’ ๆฐธไน…ใซไธไธ€่‡ด

This kind of false negative doesn't break on "did the click work (did the post go out)?" โ€” it breaks on "what did you compare?" The matching mechanism runs fine. The material it's matching is corrupted.

1. Caption normalization (strip emoji and full-width characters)

Transform the match key into a form that doesn't depend on how the DOM renders. By passing both sides โ€” the caption string and the body text pulled from the DOM โ€” through the same function, neither side can drop something the other keeps.

def normalize_caption_key(text: str) -> str:
    import unicodedata
    text = "".join(c for c in text if not c.isspace())
    text = "".join(
        c for c in text
        if ord(c) <= 0xFFFF
        and unicodedata.category(c) not in ("So", "Sk")
    )
    return text[:24] if len(text) >= 24 else text

The match width grew from 12 characters to 24 as a margin for the emoji that normalization strips out. The original 12-character rule was designed for unnormalized strings; for the normalized remainder to carry the same amount of information, it has to be longer.

"Apply the same normalization to both the caption and the page body" โ€” that's the crux. Normalize only one side and the transformed lengths won't line up, producing a different mismatch.

2. API-first lookup, with the DOM fallback cut off

The DOM's inner_text depends on how IG renders things, and IG can change that at any moment. An API-first lookup removes that uncertainty.

Using the ds_user_id cookie from the logged-in page, the script queries the internal API directly. If the API returns JSON (i.e., it responded normally) but the post isn't there, the result is locked in as a failure without falling back to the DOM. Only when all 8 queries return non-JSON (the API is broken, etc.) does it fall back to a single DOM check.

API ๅ•ใ„ๅˆใ‚ใ› (ๆœ€ๅคง8ๅ›ž)
        โ”‚
        โ”œโ”€ JSON ่ฟ”็ญ”ใ‚ใ‚Š + ๆŠ•็จฟใ‚ใ‚Š  โ†’ ok: true
        โ”œโ”€ JSON ่ฟ”็ญ”ใ‚ใ‚Š + ๆŠ•็จฟใชใ—  โ†’ ok: false๏ผˆDOM ใซ้€ƒใŒใ•ใชใ„๏ผ‰
        โ””โ”€ 8ๅ›žใจใ‚‚ JSON ไปฅๅค–         โ†’ DOM ใ‚’1ๅ›žใ ใ‘็ขบ่ช โ†’ ok / false

This is designed so that "couldn't verify" is never mistranslated as "not posted." If you escape to the DOM whenever the API is down, you're right back to inner_text matching, and the false-negative path stays open.

3. Fail-closed design

When API verification is impossible, treat the default as "already posted." The reasoning is asymmetry.

A duplicate can be deleted later, but it was public for a while and someone has to delete it. A delay is absorbed by the next job. For asymmetric damage, fail-closed โ€” defaulting to the lighter outcome โ€” is the right call.

Alongside this, I introduced an already-live verdict so that posts that are already public don't get recorded as failures in the ledger.

return {"ok": True, "note": "already-live"}

If it lands in the ledger as ok:false, the next retry check treats it as "not posted." Recording ok:true is what makes the duplicate guard actually work.

All three fixes landed without touching IG at all โ€” static implementation changes plus pure-function tests (pytest 12 passed). The pre-post duplicate check (has_existing_caption()) was also wired in as a shared function for both the carousel and reel lanes, and it likewise stops fail-closed when the API can't be verified.

normalize_caption_key โ€” why it ended up this way Let's break the normalization function from above down one more level.

def normalize_caption_key(text: str) -> str:
    import unicodedata
    text = "".join(c for c in text if not c.isspace())
    text = "".join(
        c for c in text
        if ord(c) <= 0xFFFF
        and unicodedata.category(c) not in ("So", "Sk")
    )
    return text[:24] if len(text) >= 24 else text

The condition ord(c) <= 0xFFFF keeps only characters within the BMP (Basic Multilingual Plane). Most emoji sit at U+1F000 and above, meaning ord exceeds 65535. ๐Ÿ‘พ has an ord of 128126, so it's excluded immediately. The Unicode categories So (Symbol, Other) and Sk (Symbol, Modifier) are also removed because decorative symbols exist inside the BMP too. โ˜… (U+2605, category So), for instance, fits in the BMP but can't be relied on to appear consistently in inner_text.

The 24-character match width was worked backwards from measurement. The original 12 characters were designed for unnormalized strings. After normalization, the string is shorter by however many emoji were dropped. With a caption opening like ๐Ÿ‘พ ๅ€‹ไบบ้–‹็™บใฎ้‡็”ฃ vol.XX โ€”โ€”, the first 12 characters after ๐Ÿ‘พ falls away become ใ€Œ ๅ€‹ไบบ้–‹็™บใฎ้‡็”ฃ vใ€ โ€” a fairly generic string. Extending to 24 reads through ใ€Œ ๅ€‹ไบบ้–‹็™บใฎ้‡็”ฃ vol.18 โ€”โ€”ใ€, which is much less likely to collide with another post.

The key point is what the function is applied to. The same normalize_caption_key goes over both the caption string and the page body pulled from the DOM. Normalize only one side and the post-transform character counts diverge, producing a fresh mismatch. What pytest verified was exactly this scenario: "do both sides match after going through the function?"

def test_normalize_both_sides():
    caption = "๐Ÿ‘พ ๅ€‹ไบบ้–‹็™บใฎ้‡็”ฃ vol.18 โ€”โ€” "
    dom_text = " ๅ€‹ไบบ้–‹็™บใฎ้‡็”ฃ vol.18 โ€”โ€” "  # IG ใŒ img ใซๅค‰ๆ›ใ—ใŸๅพŒ
    assert normalize_caption_key(caption) == normalize_caption_key(dom_text)

It's a pure function, so it can be verified without connecting to IG at all. That's what "12 passed in pytest with no IG connection" actually means.

Turning the flow above into code, the decision branches look like this:

def _verify_via_api(shortcode: str, caption: str) -> dict:
    for attempt in range(8):
        resp = _fetch_profile_api(user_id=DS_USER_ID, cursor=...)
        if resp is None:  # JSONไปฅๅค–ใŒ่ฟ”ใฃใฆใใŸ
            continue
        for post in resp.get("edges", []):
            node_text = post["node"].get("edge_media_to_caption", {})
            key = normalize_caption_key(node_text.get("edges", [{}])[0].get("node", {}).get("text", ""))
            if key and key == normalize_caption_key(caption):
                return {"ok": True, "source": "api"}
        return {"ok": False, "source": "api", "reason": "not_found"}
    return _verify_via_dom(caption)

The core is the asymmetry between the resp is None branch and the return {"ok": False} branch. If "the API is up and returned JSON" but "the post isn't found," that is definitive information: it wasn't posted. There's no reason to escape to the DOM. Only when "none of the 8 attempts returned JSON" does it fall back to a DOM check.

Why 8? Requests to the internal API sometimes hit Instagram's rate limiting and return normally after one or two failures. But if all 8 come back non-JSON, either the API is broken or the session has expired, and asking further won't change the outcome.

The reason for using the ds_user_id cookie is that the logged-in internal API returns post lists as JSON even for profiles you don't follow. Pagination uses the after cursor when needed, but landing verification only checks the most recent posts, so one page is enough.

already-live ledger design

def _verify_landed(shortcode: str, caption: str) -> dict:
    result = _verify_via_api(shortcode, caption)
    if result.get("source") == "api":
        return result
    dom_result = result
    if dom_result.get("ok") is None:  # ็ขบ่ชไธ่ƒฝ
        return {"ok": True, "note": "unverified-assumed-live"}
    return dom_result

The record format ok: True, note: "already-live" exists because the ledger reader uses ok:true for its skip decision. Recorded as ok:false, the entry gets reprocessed as "not posted" at the next retry. The note field is there so a human reading the ledger can see "why was this treated as success?" โ€” the script logic doesn't use it. Without that distinction, three months from now you'll open the ledger and have no answer to "why is this one true?"

A second check based on post-count delta is included as well. It fetches the profile's post count before and after posting; if the difference is +1, it declares success independently of caption matching. This is a safety net for cases where caption normalization fails to match for whatever reason. Both checks use the same internal API, but they look at different "material" (caption vs. count), so if one breaks, the verdict doesn't die completely.

On August 30, two days after the August 28 fix (normalization + API-first + fail-closed), 16 carousels with no body text went public. Two patterns, eight rounds each.

The symptom was "duplicates again," but this time the cause wasn't matching โ€” it was the posting side. keyboard.insert_text in ig_autopost.py never made it into the caption field, and the screenshots showed "Share" being pressed at 0/2200.

This incident was nastier than the previous one, as my learning notes put it:

๐Ÿ”ด The pre-post duplicate guard (has_existing_caption) and the post-post landing check (_verify_landed) share the same internal API and the same 24-character key (normalize_caption_key). With an empty body, neither matches โ†’ the duplicate guard says "not out yet," the landing check says "not out" โ†’ treated as failure, same ID re-posted.

Extracting the pre-post duplicate check into a shared function on August 28 was the right call. But as a result of sharing it, the failure mode was shared too. A single function is correct from a reuse standpoint, and it's fine as long as the caption goes in properly. But against the path where the caption comes out empty, every guard that shares it is neutralized simultaneously.

The fix came in two stages.

Stage one: confirm the caption went in before posting.

def insert_caption_with_verify(page, caption: str) -> None:
    for method in [_keyboard_insert, _clipboard_paste, _js_set_value]:
        method(page, caption)
        actual = page.locator('[data-lexical-editor]').inner_text()
        if len(actual) >= len(caption) - 5:  # 5ๆ–‡ๅญ—ใƒžใƒผใ‚ธใƒณ
            return
    raise RuntimeError(f"ใ‚ญใƒฃใƒ—ใ‚ทใƒงใƒณๆŠ•ๅ…ฅๅคฑๆ•—: ๆœŸๅพ…{len(caption)}ๆ–‡ๅญ—/ๅฎŸๆธฌ{len(actual)}ๆ–‡ๅญ—")

"Did it go in?" is decided by the actual character count in the field, not by the return value of keyboard.insert_text. The API can report "inserted" while the DOM rendering hasn't caught up. In a live run, it printed ใ‚ญใƒฃใƒ—ใ‚ทใƒงใƒณๆŠ•ๅ…ฅ 1257ๆ–‡ๅญ— / ๆœŸๅพ…1257ๆ–‡ๅญ— and then landing verification succeeded in 13 seconds.

Stage two: block the path where the match key becomes empty, at the entrance.

def normalize_caption_key(text: str) -> str:
    result = text[:24] if len(text) >= 24 else text
    if not result:
        raise ValueError("็…งๅˆใ‚ญใƒผใŒ็ฉบ: ๆŠ•็จฟๅ‰ใซไธญๆญขใ—ใฆใใ ใ•ใ„")
    return result

An empty key isn't "no match" โ€” it's a third state: "the comparison never happened." Record the result of a comparison that never happened as ok:false in the ledger and a retry fires, multiplying the side effect. Stopping at the entrance with an explicit error breaks the chain of "can't compare โ†’ re-post."

On September 8, I discovered that post_reel_tiktok.py had been exiting with RuntimeError every time since September 24. For 15 days, the ledger accumulated a daily ok:false. But when I checked the TikTok dashboard, every single post had succeeded.

The cause: the success check depended on the vendor's fixed wording. After clicking Post, the script polled the page body for 120 seconds waiting for one of five fixed strings to appear.

SUCCESS_TEXTS = [
    "ๅ‹•็”ปใŒๆŠ•็จฟใ•ใ‚Œใพใ—ใŸ",
    "ๆŠ•็จฟใ‚’ไฝœๆˆใ—ใพใ—ใŸ",
    "ใ‚ณใƒณใƒ†ใƒณใƒ„ใŒๅ…ฌ้–‹ใ•ใ‚Œใพใ—ใŸ",
]

TikTok was navigating to the content management page after posting (URL containing /tiktokstudio/content), but the sidebar label on that screen had changed from ใ€Œ็ฎก็†ใ™ใ‚‹ใ€ to ใ€Œ็ฎก็†ใ€. Nothing in SUCCESS_TEXTS matched, so it threw RuntimeError every time.

There are two reasons it went unnoticed for 15 days. First, the ledger's skip logic only looked at ok:true entries. Video IDs recorded as ok:false became candidates for reprocessing as "not yet posted" on the next run. They had been posted, so had a re-run actually happened, the same video would have gone public twice. The only reason no duplicates appeared is that the ok:false IDs didn't happen to reach the front of the queue โ€” new videos were prioritized.

Second, the failure exception carried neither the page URL nor the start of the body. A single line โ€” RuntimeError: ใ‚ฟใ‚คใƒ ใ‚ขใ‚ฆใƒˆ: ๆˆๅŠŸๆ–‡่จ€ใŒ่ฆ‹ใคใ‹ใ‚Šใพใ›ใ‚“ โ€” gives you no way to diagnose "TikTok changed its wording." When an error leaves no evidence at the scene, recovery time balloons. The 15 days were caused by that missing record.

The fix was to layer the checks.

def _is_posted_successfully(page) -> bool:
    if any(t in page.inner_text("body") for t in SUCCESS_TEXTS):
        return True
    if "/tiktokstudio/content" in page.url:
        return True
    if normalize_caption_key(caption)[:12] in page.inner_text("body"):
        return True
    return False

Check 3 โ€” "verify using the caption I submitted" โ€” is the most robust. Even if the vendor changes the UI, if I'm the one who posted, my own text will be on the screen. Checks 1 and 2, which depend on the vendor's vocabulary, become fallbacks.

My learning notes record it like this:

A check that depends on vendor wording can't be fixed unless you preserve the evidence at the moment it fails

Designing your error records matters as much as designing your retries. Always attaching page.url and the first 200 characters of body to the exception dates from this incident.

On September 10, I noticed that every quote post since September 5 had been counted as quoted=0. They had actually been posted, but the landing verification script returned unverified for all of them and never incremented the count.

The cause was Playwright's strict mode combined with a DOM structure change.

// ๅค‰ๆ›ดๅ‰
const tweetText = await article.locator('[data-testid="tweetText"]').innerText()

A quote post's article element contains two tweetText nodes: my own text and the embedded original post. In strict mode, Playwright's innerText() throws when multiple elements match. That error was caught and turned into an empty string, the comparison fell to unverified, and the count stayed at quoted=0.

The fix is .first() to read only the first element.

const tweetText = await article.locator('[data-testid="tweetText"]').first().innerText()

A one-line change, but it stopped five days of false alerts. health.mjs had been pushing "0 today" notifications to the desktop, so for five days the state was "all quote posts are failing, cause unknown." In reality nothing was broken and every post had succeeded.

This incident has exactly the same structure as TikTok's 15 straight days of exit 1. "When the vendor's DOM goes from one element to multiple, a strict comparison falls to failure and produces a false negative" โ€” same structure, different platform, two weeks later. If I'd been checking that pattern across lanes after learning it once, it would have been closed in 0 days, not 5.

This is the one that stung most. I knew this failure mode, and still couldn't prevent its recurrence on another platform. After the fix, I preemptively added .first() to the reply.mjs in every lane using the same kind of comparison โ€” but that was after the fact.

The 16 empty-caption posts on August 30, TikTok's 15 consecutive exit 1s, X quote posts at zero for 5 days โ€” what they share is this structure: the comparator can't tell you that the comparator itself is broken.

The script keeps returning ok:false. The ledger accumulates failures. The scheduler books re-runs. Nowhere in that chain is there a signal saying "the input to the verdict is broken." The script is working correctly โ€” with the wrong material.

My retry-design reference (retry-and-giveup-design.md) has this line:

Throwing the same failure three times doesn't change the result. Suspect what you're passing in, not the count.

All three cases are exactly this. Cut retries to two, leave them at three โ€” as long as the comparison material is broken, nothing changes. When the verdict falls the same way three times in a row โ€” consecutive false negatives โ€” the first thing to suspect is "what am I using as comparison material?"

inner_text's rendering dependency, a vendor changing fixed wording, strict mode's element-count sensitivity โ€” all three are the pattern "the comparison logic I wrote is correct, but the outside environment invalidated the comparison." Adjusting retry counts is powerless against this pattern. You either change the material or change how you compare. There's no third option.

In my current environment, when landing verification fails, the next step is not an immediate retry but asking "why did it fail?" through a separate path. On failure, it hits the API directly to check "does the post exist?" If it does, it's recorded as already-live. Only if it doesn't is "genuinely not posted" confirmed. That one extra step โ€” "verify, then decide" โ€” is the last wall against duplicated side effects.

A full list of the landmines I actually stepped on. Every one follows the pattern "the script exits normally, and only the real world is broken."

Symbols other than emoji also vanish from inner_text. โ˜… (U+2605, Unicode category So) is a BMP character, but it can't be relied on to appear consistently in inner_text under IG's rendering. I thought ord(c) <= 0xFFFF was enough; this is why categories So (Symbol, Other) and Sk (Symbol, Modifier) are additionally stripped. Base normalization on "is it in the BMP?" alone and the next incident is on its way.

Normalizing the match key on only one side. Normalize just the caption string and compare against the raw DOM text, and the character counts diverge by however many emoji were dropped, creating a new mismatch. Always pass normalize_caption_key over both sides โ€” the easiest thing to forget in the whole implementation.

Not closing the path where the match key becomes empty. This was the root cause of the 16 body-less carousels on August 30. keyboard.insert_text in ig_autopost.py never reached the caption field, and "Share" was pressed at 0/2200. The pre-post duplicate guard (has_existing_caption) and the post-post landing check (_verify_landed) share the same 24-character key, so the moment the key goes empty, both are neutralized at once. The duplicate guard says "not out yet," the landing check says "not out," and the same content keeps getting retried.

"Prevention" and "detection" sharing the same comparator. Correct from a code-reuse perspective, but the instant that function breaks, prevention and detection die simultaneously. A hidden single point of failure. At least one of them needs to verify with different material (post-count delta, API count).

Success checks that depend on the vendor's fixed wording. TikTok navigated to a different page after posting and the sidebar label changed from ใ€Œ็ฎก็†ใ™ใ‚‹ใ€ to ใ€Œ็ฎก็†ใ€ โ€” that alone broke all five registered strings. post_reel_tiktok.py threw RuntimeError on every post and stacked ok:false for 15 days. Only opening the TikTok dashboard directly revealed that everything had posted.

Failure exceptions with no page URL and no body prefix. One line โ€” RuntimeError: ใ‚ฟใ‚คใƒ ใ‚ขใ‚ฆใƒˆ: ๆˆๅŠŸๆ–‡่จ€ใŒ่ฆ‹ใคใ‹ใ‚Šใพใ›ใ‚“ โ€” can't diagnose "TikTok changed its wording." Because page.url and the first 200 characters of body weren't in the exception, isolating the cause took 15 days.

Playwright strict mode throwing on multiple elements. An X (formerly Twitter) quote post's article element has two [data-testid="tweetText"] nodes โ€” my own text and the embedded original. innerText() threw in strict mode, returned an empty string, the comparison fell to unverified, and every quote post since September 5 was counted as quoted=0. One added .first() fixed it, but it took five days to notice.

A design that mistranslates "couldn't verify" as "not posted." Fall back to the DOM when the API is down and you're back to inner_text matching. "The API returned JSON but the post isn't found" is definitive (not posted); "none of 8 attempts returned JSON" just means it couldn't be verified. Escape to the DOM without distinguishing the two and the false-negative recurrence path stays open.

Throwing the same failure three times with more attempts. note-autolike's rewrite produced zero output for three days because the validator rejected titles over 58 characters while the generation prompt said nothing about a length limit. askClaude simply re-sent the identical prompt three times on failure, and got the same 63-character title three times. A constraint has to be written in both the validator and the generation prompt to function.

Exiting 0 immediately on slot-acquisition failure. browser-slot.sh printed SKIP: global limit reached and exited the instant it couldn't grab one of the global slots (3), so 12 jobs in the 12:00 hour, 9 in the 09:00 hour, and 8 in the 11:00 hour vanished without ever starting. After adding a 600-second wait option and randomizing the retry interval to 15โ€“45 seconds, misfires dropped to nearly zero. A fixed interval makes every job stampede at the same instant (thundering herd), so the random spread is mandatory.

A fallback that lands on the same wall. In gen_note_thumbs.py I added a three-tier fallback โ€” real Chrome โ†’ bundled chromium โ†’ env โ€” and all three tiers ended in the same 90-second rc=-9. Same symptom doesn't mean same cause. Before wiring in a fallback, measure once whether that fallback hits a different wall than the original.

IG's delete API returning 200 without deleting. Call the delete API with an expired session (sessionid) and it returns 200 with an HTML body. Trying to delete the three August 28 duplicates, not one was removed. Don't define success as "the API returned 200" โ€” the exact same lesson as post verification.

Best-effort pass-through that only surfaces downstream. In IG reel crop-ratio selection, there was a branch that silently passed through when query_selector returned None. The label varied by day โ€” ใ€Œใ‚ชใƒชใ‚ธใƒŠใƒซใ€, ใ€ŒOriginalใ€, ใ€Œ9:16ใ€, ใ€Œๅ…ƒใฎๅ†™็œŸใ€, ใ€Œๅ…ƒใฎๆฏ”็އใ€ โ€” while the code's candidate list was fixed. 9:16 wasn't selected, "Next" was pressed at 1:1, and a video with the sides cropped off went public on the grid. Select/toggle operations need measured confirmation that the selection took before moving to the next step.

Trusting the note CTA paste result. On September 2, note.com changed its behavior to drop quote blocks. The script verified by matching a signature string at the end, so it judged the stripped articles as "not applied" and kept re-pasting โ€” producing 59 duplicate CTAs and 31 missing signatures. It should have verified by reading back the published page, not the editor's "inserted" record.

Reproducing the same-shaped bug on another platform. I'd learned the structure "DOM goes from one element to multiple โ†’ strict comparison falls to failure โ†’ false negative" on TikTok. Two weeks later I hit the exact same structure on X. The result of having no habit of preemptively fixing same-shaped spots across all lanes at fix time. Checking a pattern learned in one place across the board would have closed it in 0 days, not 5.

Here are the patterns actually in use in my environment now, distilled from the pitfalls above.

1. Always pass both sides of a match key through the same normalization function

def normalize_caption_key(text: str) -> str:
    import unicodedata
    text = "".join(c for c in text if not c.isspace())
    text = "".join(
        c for c in text
        if ord(c) <= 0xFFFF
        and unicodedata.category(c) not in ("So", "Sk")
    )
    return text[:24] if len(text) >= 24 else text

Run the same function over both the caption string and the DOM-extracted text. One side alone leaves the post-transform lengths out of sync and produces a new mismatch.

2. Turn an empty match key into an error at the entrance

result = text[:24] if len(text) >= 24 else text
if not result:
    raise ValueError("็…งๅˆใ‚ญใƒผใŒ็ฉบ: ๆŠ•็จฟๅ‰ใซไธญๆญขใ—ใฆใใ ใ•ใ„")
return result

An empty key isn't "no match (ok:false)" โ€” it's the third state, "the comparison never happened." Stack it as ok:false in the ledger and the next retry fires, multiplying side effects.

3. Verify side-effecting operations against the other side's actual state

def _verify_via_api(shortcode: str, caption: str) -> dict:
    for attempt in range(8):
        resp = _fetch_profile_api(user_id=DS_USER_ID, cursor=...)
        if resp is None:
            continue
        for post in resp.get("edges", []):
            key = normalize_caption_key(...)
            if key and key == normalize_caption_key(caption):
                return {"ok": True, "source": "api"}
        return {"ok": False, "source": "api", "reason": "not_found"}
    return _verify_via_dom(caption)

"The API returned JSON but the post isn't found" is definitive โ€” don't escape to the DOM. Only when none of 8 attempts returns JSON, use the DOM exactly once. "Couldn't verify" and "not posted" are different states.

4. Fail closed โ€” treat "unverifiable" as "already posted"

The damage from a duplicate (cleanup work, time spent public) and from a delay (absorbed by the next job) is asymmetric. When you can't verify, pick the lighter one and record {"ok": True, "note": "unverified-assumed-live"} in the ledger. Recording ok:true is what prevents the next retry.

5. Use different material for "prevention" and "detection"

If the pre-post duplicate guard and the post-post landing check share the same 24-character key, one breaking kills both. Run the post-count delta (difference in profile post count before and after) as an independent verification material. With caption and count moving independently, one breaking doesn't kill the verdict completely.

6. Judge caption insertion by the field's actual character count

def insert_caption_with_verify(page, caption: str) -> None:
    for method in [_keyboard_insert, _clipboard_paste, _js_set_value]:
        method(page, caption)
        actual = page.locator('[data-lexical-editor]').inner_text()
        if len(actual) >= len(caption) - 5:
            return
    raise RuntimeError(f"ใ‚ญใƒฃใƒ—ใ‚ทใƒงใƒณๆŠ•ๅ…ฅๅคฑๆ•—: ๆœŸๅพ…{len(caption)}ๆ–‡ๅญ—/ๅฎŸๆธฌ{len(actual)}ๆ–‡ๅญ—")

"It went in" is decided by the field's actual character count, not the API's return value. Try three methods and verify by reading back โ€” in a live run it printed ใ‚ญใƒฃใƒ—ใ‚ทใƒงใƒณๆŠ•ๅ…ฅ 1257ๆ–‡ๅญ— / ๆœŸๅพ…1257ๆ–‡ๅญ— and then passed landing verification in 13 seconds.

7. Judge success by the text you submitted, not vendor wording

def _is_posted_successfully(page, caption) -> bool:
    if any(t in page.inner_text("body") for t in SUCCESS_TEXTS):
        return True
    if "/tiktokstudio/content" in page.url:
        return True
    if normalize_caption_key(caption)[:12] in page.inner_text("body"):
        return True
    return False

Check 3 โ€” "verify using the caption I submitted" โ€” is the hardest to break. The vendor can change its UI, but the text I posted will be on the screen. Fixed strings are the fallback.

8. Always attach page.url and the first 200 characters of body to failure exceptions

When a vendor-wording-dependent check breaks, diagnosis takes 15 days if there's no evidence at the scene. Always put page.url and page.inner_text("body")[:200] in the exception's one line.

9. Use .first() explicitly under Playwright strict mode, and add it preemptively across all lanes

// ๅค‰ๆ›ดๅ‰: strict modeใงthrow
const tweetText = await article.locator('[data-testid="tweetText"]').innerText()
// ๅค‰ๆ›ดๅพŒ
const tweetText = await article.locator('[data-testid="tweetText"]').first().innerText()

At fix time, check every lane using the same kind of comparison and apply the same fix. This is the habit that prevents "learned it in one place, recurred on another platform."

10. When the same failure happens three times, suspect what you're passing in, not the count

Exactly as written in retry-and-giveup-design.md โ€” "Throwing the same failure three times doesn't change the result. Suspect what you're passing in, not the count." Deterministic failures (validation violations, expired logins, vendor wording changes) must be classified before retrying, or adding attempts just burns resources.

11. Set retry counts by "how many times may this side effect occur?"

Attaching three retries to a non-idempotent operation declares that you'll tolerate the side effect up to three times. Before designing retries for social posts, ask "is this operation idempotent?" If not, always insert a step that checks the current state before retrying.

12. Wait up to 600 seconds for a slot instead of skipping immediately

BROWSER_SLOT_WAIT_SEC=600  # 0ใซใ™ใ‚Œใฐๅพ“ๆฅใฎๅณskip
sleep $((RANDOM % 30 + 15))

BROWSER_SLOT_WAIT_SEC=0 exactly matches the old immediate-skip behavior, preserving backward compatibility with the 30+ existing launchd jobs.

13. Distinguish "waited / gave up / skipped" in the ledger log

If a record that gave up after waited=600s and a record that did nothing and hit exit 0 look like the same line, you can't tell "inefficient" from "never ran at all." To judge from the ledger whether a run of false negatives means "the comparator is broken" or "it genuinely isn't running," always log the reason.

14. When you add a retry, put fault injection in the same commit

WA_API_FAIL_ONCE=2 node index.js

Claiming you "added" a retry without fault injection proves nothing, because intermittent failures can't be reproduced and you can't show the path was actually exercised. Don't stop at showing the git diff.

15. Read back the published page before recording "applied"

Don't trust the editor's "inserted" return value. The 90 duplicate note CTAs happened because this verification step was missing. The principle reader-state-not-self-record โ€” judge by the other side's actual state, not your own send log โ€” applies to both post verification and paste verification.

In roughly two weeks starting August 28, three platforms broke the same way in succession. IG's 3 duplicates, TikTok's 15 consecutive exit 1s, X quote posts at zero for 5 days โ€” every one has the structure "the comparator works fine, but the material it compares is broken."

The script throws no errors. The ledger keeps stacking ok:false, and the scheduler calmly books re-runs. Nowhere is there a signal saying "the job is broken." You only notice when you look at reality directly from outside โ€” three carousels lined up on the profile, a post list checked on the dashboard, a ledger finally opened after a week of zero-count notifications.

One line from retry-and-giveup-design.md sums up this structure:

In my environment now, when landing verification fails, the next step isn't an immediate retry but asking "why did it fail?" through a separate path. Hit the API directly to confirm the post exists; if it does, record it as already-live with ok:true. Only if it doesn't is "genuinely not posted" confirmed.

That one extra step โ€” verify, then decide โ€” is the precondition for running 171 jobs autonomously. Building an environment means driving the number of times I have to touch anything toward zero. Keeping it at zero means continually asking what the verdict is actually based on. The comparator doesn't break. What breaks is the material the comparator is looking at.

One question for you: in your own automation, which success check is currently trusting a return value instead of reading back the other side's actual state?

The full picture of the system, the breakdown of the ยฅ1.2M/month, and the 30-day setup guide are in a paid note (Japanese).

๐Ÿ“• How I actually make money with an autonomous Claude Code environment โ€” system, examples, getting started, support

*Written by Lily โ€” I ship iOS apps and automate my content stack with Claude Code.

Follow along: Portfolio ยท X ยท GitHub*

โ”€โ”€ more in #ai-agents 4 stories ยท sorted by recency
โ”€โ”€ more on @instagram 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain โ€” perfect for shipping the agent you just read about.

$git push zahid main
โ†’ Live at https://your-agent.zahid.host โœ“
Get free account โ†’ Pricing
from โ‚ฌ0/mo ยท no card required
LIVE [news/3-duplicate-instagraโ€ฆ] indexed:0 read:29min 2026-09-17 ยท โ€”