Repository navigation
Re-read schedules before posting and keep filled posts within Discord limits - #142
Merged
Merged
Conversation
… limits The runner iterated the schedule array from the start of the tick, so a schedule deleted, paused or edited while an earlier one was posting still went out with its stale flags and text. It now re-reads each schedule at the top of the loop and again after the creator-access check. Lengths were only checked before tokens were filled, so a post near a limit could overflow at post time and fail at every occurrence. Create and edit now refuse a post that its tokens would push over a limit at any of its next 12 occurrences, and the runner clips an overflowing filled post to fit (logging schedule.clipped, never the text) instead of failing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment on lines
99
to
109
| deps.interpolator.interpolate(text, due.at), | ||
| ), | ||
| ); | ||
| if (filled.clipped) { | ||
| // Tokens made it longer than the platform allows. Never log the text itself. | ||
| log.warn({ event: "schedule.clipped", ...fields }, "filled post too long, clipped it"); | ||
| } | ||
| await deps.posts.post(current.channelId, filled.post); | ||
| log.info( | ||
| { event: "schedule.posted", ...fields, due: due.at.toISOString() }, | ||
| "posted a scheduled post", |
There was a problem hiding this comment.
Bug: The post-dispatch logic checks stale recurrence data on schedule rather than re-reading the schedule from the store, which can cause concurrent user edits to be deleted or mishandled.
Severity: LOW
Suggested Fix
Re-read the schedule from deps.store.get(schedule.id) after awaiting deps.posts.post(...). If the schedule was deleted while the post was in flight, do nothing; otherwise, check the newly fetched schedule's recurrence kind before deciding whether to remove or update it.
Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.
Location: src/features/schedules/runner.ts#L90-L109
Potential issue: After awaiting `deps.posts.post(...)`, the runner uses the stale
`schedule` instance read at the top of the pass instead of refreshing the record from
`deps.store`. If a user edits the schedule's recurrence or timing while the Discord post
is in flight, the runner evaluates `schedule.recurrence.kind === "once"` against the
outdated state. This can cause a one-off schedule that was just re-timed or converted to
repeating to be unexpectedly deleted via `deps.store.remove(schedule.id)`, or leave an
edited one-off schedule lingering in the store.
Did we get this right? 👍 / 👎 to inform future reviews.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What was wrong
1. Stale schedules in the runner.
runDuelooped over the arraystore.all()returned at the start of the tick.ScheduleStorereplaces that array on every save, so the loop kept the old objects. Say two schedules are due on the same tick. If someone ran/schedule deleteor/schedule pauseon the second one, or edited it, while the first was posting, the second still went out with its oldpausedflag and old text.2. Tokens could push a post past Discord's limits for good. Lengths were only checked on the text as typed.
{{date}}grows from 8 to up to 17 characters when filled, and{{dateWithTime}}from 16 to about 28. So a message near 2000 characters, a poll answer near 55 or a question near 300 could go over the limit at post time. Discord rejected it andchannel-poster.tsdoesn't clip, so every occurrence loggedschedule.failedplus a Sentry error, forever. The create/edit confirmation hid this because it cut the text with.slice(0, 2000).What changed
runner.ts: the loop now goes over IDs and runsstore.get(id)at the top of each pass, skipping a schedule that's gone or paused. It reads the schedule again after thestillAllowedawait, and also skips it if that occurrence is no longer due (its time was edited). What gets posted is the schedule's post as it is now.features/schedules/limits.ts:checkFilledLengthfills the tokens at each of the schedule's next 12 occurrences (or atnowif none are left) and throws a clearUserFacingErrorif any of them would be too long. It says which part and which date, and never quotes the text./schedule message,/schedule polland/schedule editcall it before saving: two one-line calls inindex.ts, kept small because that file is being edited in another PR.clipToLimitsis the fallback at post time. If a filled post is still too long (a month name we didn't check, or a future token that grows), the runner cuts it to the limit with a…and logsschedule.clippedwith only the id, channel and kind, never the template or the filled text. Before, it failed at every occurrence.docs/architecture.md(Scheduled posts),docs/runbook.md(Tokens,schedule.clipped) and aCHANGELOG.mdentry under Fixed.How it was tested
runner.test.ts: a schedule deleted, paused, edited, or moved to another time while another one posts. A schedule deleted or paused while its creator's access is being checked. A stored post that tokens push over 2000 characters gets clipped, nothing is logged at error level, and no text appears in the logs. All 6 new runner tests fail onmainand pass with this change.limits.test.ts: limit checks counted in characters (an emoji counts as one), refusing a message, question or answer that only goes over once filled, a later occurrence where a longer month name pushes it over, the fallback tonow, and clipping.schedules.test.ts: create (message and poll) and edit refuse an overflowing post and save nothing; a post exactly at the limit is still accepted.just checkpasses (lint, typecheck, tests, coverage thresholds).This doesn't touch any file listed in AGENTS.md rule 14.
🤖 Generated with Claude Code