Skip to content

Re-read schedules before posting and keep filled posts within Discord limits - #142

Merged
NinjaLikesCheez merged 3 commits into
mainfrom
fix/schedule-runner-fresh-and-limits
Oct 19, 2026
Merged

NinjaLikesCheez merged 3 commits into
mainfrom
fix/schedule-runner-fresh-and-limits

Conversation

@NinjaLikesCheez

Copy link
Copy Markdown
Contributor

What was wrong

1. Stale schedules in the runner. runDue looped over the array store.all() returned at the start of the tick. ScheduleStore replaces 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 delete or /schedule pause on the second one, or edited it, while the first was posting, the second still went out with its old paused flag 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 and channel-poster.ts doesn't clip, so every occurrence logged schedule.failed plus 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 runs store.get(id) at the top of each pass, skipping a schedule that's gone or paused. It reads the schedule again after the stillAllowed await, 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.
  • New features/schedules/limits.ts:
    • checkFilledLength fills the tokens at each of the schedule's next 12 occurrences (or at now if none are left) and throws a clear UserFacingError if any of them would be too long. It says which part and which date, and never quotes the text. /schedule message, /schedule poll and /schedule edit call it before saving: two one-line calls in index.ts, kept small because that file is being edited in another PR.
    • clipToLimits is 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 logs schedule.clipped with only the id, channel and kind, never the template or the filled text. Before, it failed at every occurrence.
  • Docs: docs/architecture.md (Scheduled posts), docs/runbook.md (Tokens, schedule.clipped) and a CHANGELOG.md entry 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 on main and 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 to now, 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 check passes (lint, typecheck, tests, coverage thresholds).

This doesn't touch any file listed in AGENTS.md rule 14.

🤖 Generated with Claude Code

… 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>
@NinjaLikesCheez NinjaLikesCheez added the bug Something isn't working label Oct 18, 2026
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",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@NinjaLikesCheez
NinjaLikesCheez merged commit a1f1320 into main Oct 19, 2026
6 checks passed
@NinjaLikesCheez
NinjaLikesCheez deleted the fix/schedule-runner-fresh-and-limits branch October 19, 2026 07:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant