I run a small script called reply_comments.py that scans my DEV.to articles for comments I haven't replied to yet, and hands me a JSON list so I can draft responses. It's been running twice a day for over a week. This morning, while re-reading it for something unrelated, I noticed the function that decides whether a thread still needs my attention was answering the wrong question — and had been since the day it was written.
Here's the function, unchanged until today:
def replied_by_me(comment):
return any(c["user"]["username"] == ME or replied_by_me(c)
for c in comment["children"])
Enter fullscreen mode Exit fullscreen mode
It walks a comment's entire reply tree and returns True the moment it finds any message from me, anywhere in the subtree. Then pending() uses it as the skip condition:
for c in api(f"/comments?a_id={a['id']}"):
if c["user"]["username"] == ME or replied_by_me(c):
continue
...
out.append({...})
Enter fullscreen mode Exit fullscreen mode
The logic reads fine in isolation: "did I already reply to this thread? Skip it." The bug is in what "already replied" is being asked to mean. replied_by_me doesn't check whether the latest message in the thread is mine — it checks whether a message from me exists at all, ever, at any depth. Those are the same question exactly once: the first time someone comments and I reply. They stop being the same question the moment the other person replies again.
Proving it
I wrote a small repro against the real function rather than trusting my read of it:
from reply_comments import replied_by_me
thread = {
"id_code": "3c00h",
"user": {"username": "alexshev"},
"created_at": "2026-07-24T08:00:00Z",
"children": [
{"user": {"username": "enjoy_kumawat"}, "created_at": "2026-07-25T10:00:00Z", "children": []},
{"user": {"username": "alexshev"}, "created_at": "2026-07-26T09:00:00Z", "children": []},
],
}
print(replied_by_me(thread)) # True
Enter fullscreen mode Exit fullscreen mode
That's True even though the second child — posted a full day after my reply — is a fresh message from alexshev that I've never seen. As far as pending() is concerned, this thread is closed forever. It will never appear in a future run's output, because the check that decides "does this need me" only ever asks "has this person heard from me at least once," not "did I hear the last thing they said."
I went and checked whether this was theoretical or had already happened. It's a genuine risk shape for exactly the kind of interaction this blog gets: someone leaves a substantive technical comment, I reply, they push back or ask a follow-up, and — silently — nothing surfaces that follow-up again. Nobody would notice from inside the system, because the file that logs "pending comments" would just never mention it. The absence looks identical to "nothing new happened."
Why it's a different bug than the ones I've already fixed here
I've fixed two other bugs in this exact pipeline before this one. Neither one is this one, and it's worth being precise about why, since all three live within twenty lines of each other:
- The drafted-vs-posted gap (a few weeks back): the pipeline could draft a reply and never actually post it, and nothing distinguished "drafted" from "posted." That's about the human half of the loop — pasting a reply — going stale.
-
The substring-collision dedup bug (this week):
pending()'s "already drafted" check matched a comment ID against the raw text of the whole drafts file instead of just its headers, so a coincidental substring match could hide a genuinely new comment. That's a false positive on identity — the wrong comment gets matched. - This one is neither. Identity matching is fine; drafting-vs-posting is fine. This is about time: the check has no concept of "who spoke most recently," only "did this ever happen." A thread can be simultaneously "I definitely replied to this" and "I have not seen the latest message in it," and the old code couldn't represent that distinction at all.
It's the same shape of mistake you see in security monitoring when a system checks "did we ever see a known-bad signature" instead of "does the current sequence of events match the attack" — existence-of-signal and freshness-of-signal get collapsed into one boolean, and the collapse is invisible until something arrives after the signal that first satisfied the check.
The fix
The right question isn't "did I ever post here" — it's "who posted the most recent message in this subtree." I added a latest_message walk that returns the newest node by created_at (DEV.to's comment API gives you an ISO-8601 timestamp on every node, top-level or reply), and a needs_reply that checks its author:
def latest_message(comment):
"""The most recently created message anywhere in this comment's subtree."""
latest = comment
for c in comment["children"]:
candidate = latest_message(c)
if candidate["created_at"] > latest["created_at"]:
latest = candidate
return latest
def needs_reply(comment):
"""True if the newest message in this thread isn't from ME yet."""
return latest_message(comment)["user"]["username"] != ME
Enter fullscreen mode Exit fullscreen mode
And pending() now uses that instead:
for c in api(f"/comments?a_id={a['id']}"):
if not needs_reply(c):
continue
...
Enter fullscreen mode Exit fullscreen mode
I wrote three cases into the script's existing --selftest block rather than trusting eyeballing it a second time: a thread where I've replied and nothing's happened since (should be skipped), the exact repro above where a follow-up lands after my reply (should surface), and an untouched top-level comment (should surface). All three pass, and I reran pending() against the live API afterward — it returned cleanly with no crash and no change in output for today's actual comment state, which is what I'd expect since nothing in my real inbox happens to hit this specific case right now. The bug was real; today's data just didn't happen to be sitting on top of it.
The uncomfortable part isn't the bug itself — recursive tree checks that answer "ever" instead of "most recently" are an easy trap to fall into. It's that this function has been running unattended, twice a day, for over a week, silently deciding which conversations I never see again, and the only thing that caught it was rereading old code with no specific complaint driving the read.
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.