Skip to content

fix(core): unschedule a scheduled publish that publishing refuses - #4782

Open
eisenbruch wants to merge 3 commits into
emdash-cms:mainfrom
eisenbruch:fix/scheduled-publish-failure-reason
Open

eisenbruch wants to merge 3 commits into
emdash-cms:mainfrom
eisenbruch:fix/scheduled-publish-failure-reason

Conversation

@eisenbruch

@eisenbruch eisenbruch commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

What does this PR do?

Closes #4781.

When the scheduler's publish is refused with VALIDATION_ERROR or SLUG_CONFLICT, EmDashRuntime.handleContentPublish now treats it like a content:beforePublish cancellation: it unschedules the entry through handleScheduledPolicyRejection, records the error message, refreshes media usage and runs content:afterUnschedule. The dashboard's existing notice lists the entry with the message, and a later successful schedule or publish clears it, as for plugin rejections.

  • Which failures. Only codes a retry cannot clear: the entry has to change first. CONFLICT, NOT_DUE, CONTENT_PUBLISH_ERROR and anything else leave the schedule for the next pass, as today.
  • The record. ScheduledPolicyRejection.pluginId becomes optional and the record gains an optional code. A plugin rejection is written exactly as before. isScheduledPolicyRejection accepts a record without pluginId.
  • The reason. Error messages are not plugin reasons, so toScheduledRejectionReason turns control characters into spaces and cuts the message to the 500-code-point limit. Otherwise isScheduledPolicyRejection would hide the record from the list while the count still included it.
  • Unschedule fails. If the unschedule loses to a concurrent edit, the record is removed again (existing behavior of handleScheduledPolicyRejection), the failure is logged, and the next pass tries again.
  • Dashboard wording. The notice said "Publication policy blocked scheduled content", which is wrong for a slug conflict. It now says "Scheduled content was not published", and the description says to fix the reason shown rather than "resolve the policy reason". This changes five translated strings, including the overflow line ("One more entry is not shown.").
  • Docs. reference/content-lifecycle.mdx describes the new case next to the plugin one.

The calendar's "Scheduled publishing may not be running" banner is unchanged. A refused entry is no longer scheduled, so it no longer reaches that banner.

Type of change

  • Bug fix
  • Feature (requires an accepted feature plan or RFC)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Your connection to this change

  • I use EmDash and ran into the problem this PR addresses
  • I maintain a tool, dependency, or service that EmDash uses, and this change concerns that integration
  • A Maintainer approved this specific change before I opened the PR

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes (tsgo --noEmit in core and admin)
  • pnpm lint passes (oxlint --type-aware --deny-warnings on the changed files)
  • pnpm test passes (or targeted tests for my change): core unit tests, 512 files, 6,764 passed; admin Dashboard.test.tsx 28 passed
  • pnpm format has been run (oxfmt --ignore-path .gitignore --check . clean)
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable). Do not include messages.po changes except in translation PRs — a workflow extracts catalogs on merge to main.
  • I have added and reviewed the user-facing changeset (if this PR changes a published package)
  • New features link to their merged design PR: not a feature
  • I have included screenshots below if this PR changes the UI

AI assistance disclosure

  • This PR includes AI-generated code or text — model/tool: Claude Opus 5.5 (Claude Code)

Screenshots / test output

Taken on pnpm ux:site:start (editorial-small). That site does not run the scheduler, so both states were set up in its database: a scheduled post, due two hours earlier, whose staged slug another post holds.

Before (main): the post stays scheduled, and the calendar panel says the scheduler may not be running.

Calendar side panel for Draft Post: State Overdue, was due Mon, Nov 9, 3:00 PM UTC, and the warning "This entry was due 2 hours ago but hasn't published. Scheduled publishing may not be running."

After (this branch): the record the scheduler now writes for that post, as the dashboard lists it.

Dashboard notice "Scheduled content was not published", with the description "Open the affected entry, fix the reason shown, and schedule it again", and the post's link next to the message "Cannot publish: slug 'first-post' is already used by another entry in this collection. Choose a different slug." and a Dismiss button

Tests (tests/unit/scheduled-publish.test.ts), through runScheduledTasks() on a post whose staged slug another post holds:

  • the post is unscheduled and the record holds code: "SLUG_CONFLICT", the message, and no pluginId;
  • a post whose staged slug is blank is refused with VALIDATION_ERROR ("Cannot publish routable content without a slug"): its schedule is cleared, its draft kept, and the message recorded;
  • the next pass does not call publish for it again;
  • publishing it after the slug is fixed removes the record;
  • a failure that is not a refusal (CONTENT_PUBLISH_ERROR) leaves the post scheduled with no record;
  • toScheduledRejectionReason handles control characters, the length limit and empty input.

Without the runtime change, the first three fail; with VALIDATION_ERROR taken out of the refusal codes, the blank-slug test fails. Admin: a record with a code and no pluginId is listed with its message.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RktNvhjty22ohh1GSHXGMq

When the scheduler's publish fails with VALIDATION_ERROR or SLUG_CONFLICT,
the entry is unscheduled and the error message is recorded, as a plugin's
content:beforePublish rejection already is. Before, the error went only
to the log, every tick retried it, and the calendar said scheduled
publishing might not be running.

The stored rejection's pluginId becomes optional and gains a code. The
dashboard notice no longer says the cause was a publication policy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RktNvhjty22ohh1GSHXGMq
@changeset-bot

changeset-bot Bot commented Nov 9, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: fdf892b

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 12 packages
Name Type
emdash Patch
@emdash-cms/admin Patch
@emdash-cms/cloudflare Patch
@emdash-cms/plugin-test Patch
@emdash-cms/sandbox-workerd Patch
@emdash-cms/auth Patch
@emdash-cms/blocks Patch
create-emdash Patch
@emdash-cms/gutenberg-to-portable-text Patch
@emdash-cms/x402 Patch
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Nov 9, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/@emdash-cms/admin@4782

@emdash-cms/auth

npm i https://pkg.pr.new/@emdash-cms/auth@4782

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/@emdash-cms/auth-atproto@4782

@emdash-cms/blocks

npm i https://pkg.pr.new/@emdash-cms/blocks@4782

@emdash-cms/cloudflare

npm i https://pkg.pr.new/@emdash-cms/cloudflare@4782

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/contentful-to-portable-text@4782

emdash

npm i https://pkg.pr.new/emdash@4782

create-emdash

npm i https://pkg.pr.new/create-emdash@4782

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/@emdash-cms/gutenberg-to-portable-text@4782

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/@emdash-cms/plugin-cli@4782

@emdash-cms/plugin-test

npm i https://pkg.pr.new/@emdash-cms/plugin-test@4782

@emdash-cms/plugin-types

npm i https://pkg.pr.new/@emdash-cms/plugin-types@4782

@emdash-cms/registry-client

npm i https://pkg.pr.new/@emdash-cms/registry-client@4782

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/@emdash-cms/registry-lexicons@4782

@emdash-cms/registry-loader

npm i https://pkg.pr.new/@emdash-cms/registry-loader@4782

@emdash-cms/registry-moderation

npm i https://pkg.pr.new/@emdash-cms/registry-moderation@4782

@emdash-cms/registry-verification

npm i https://pkg.pr.new/@emdash-cms/registry-verification@4782

upgrade-emdash

npm i https://pkg.pr.new/upgrade-emdash@4782

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/@emdash-cms/sandbox-workerd@4782

@emdash-cms/x402

npm i https://pkg.pr.new/@emdash-cms/x402@4782

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-moderation@4782

@emdash-cms/plugin-ai-search

npm i https://pkg.pr.new/@emdash-cms/plugin-ai-search@4782

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/@emdash-cms/plugin-atproto@4782

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/@emdash-cms/plugin-audit-log@4782

@emdash-cms/plugin-color

npm i https://pkg.pr.new/@emdash-cms/plugin-color@4782

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/@emdash-cms/plugin-embeds@4782

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/@emdash-cms/plugin-field-kit@4782

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/@emdash-cms/plugin-forms@4782

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/@emdash-cms/plugin-webhook-notifier@4782

commit: fdf892b

@eisenbruch
eisenbruch marked this pull request as ready for review November 9, 2026 17:31

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Both reviewers agree that routing VALIDATION_ERROR and SLUG_CONFLICT publish refusals through the existing scheduled-rejection flow is the right approach. It unschedules the entry, records the reason, and shows the dashboard notice, with no second mechanism. Neither found a confirmed runtime regression, and both reviewed statically. Three items need fixing: the changeset omits a public type change (ScheduledPolicyRejection.pluginId is now optional), the VALIDATION_ERROR case has no regression test, and a new helper has a narrative docblock that breaks the comment convention. One dashboard string still says "blocked" and is a minor suggestion.


Reviewed by anthropic/claude-opus-5-5 and openai/gpt-6.1-sol.


Findings

  • [needs fixing] .changeset/scheduled-publish-refusal.md:6

    ScheduledPolicyRejection and isScheduledPolicyRejection are part of the public emdash API (packages/core/src/index.ts lines 290 and 403). This PR makes ScheduledPolicyRejection.pluginId optional and adds an optional code field.

    Any integration that reads the emdash:scheduled-policy-rejection:* options will be affected:

    • After narrowing with isScheduledPolicyRejection, rejection.pluginId is now typed string | undefined, so the TypeScript build fails.
    • At runtime, the new records have no pluginId at all.

    The changeset only describes the dashboard behavior. Per .changeset/README.md ("Name the user-facing API…") and AGENTS.md's backwards-compatibility rule, it should name this API change and tell consumers what to do. For example, add:

    ScheduledPolicyRejection.pluginId is now optional: records written for a refused publish have no pluginId and carry the publish error's code instead. Code that reads these records should handle a missing pluginId.

  • [suggestion] packages/admin/src/components/Dashboard.tsx:220

    The PR renames the banner because "Publication policy blocked" is wrong for a slug conflict. The overflow line still says "One more blocked entry is not shown." / "# more blocked entries are not shown.", which uses the old policy wording for entries that may have been refused for validation or slug reasons. Consider matching the new title, for example "One more entry is not shown." / "# more entries are not shown."

  • [needs fixing] packages/core/tests/unit/scheduled-publish.test.ts:821

    The new runtime tests exercise SLUG_CONFLICT, but none produces the VALIDATION_ERROR that caused the reported production failure. The dashboard test only supplies a prebuilt rejection record, so removing VALIDATION_ERROR from the refusal set would leave all these tests passing while restoring that failure. Please add a real-database regression through runScheduledTasks() for a validation refusal—for example, a due routable entry with a blank staged slug—and assert that its schedule is cleared and its validation message is persisted in a dashboard-readable rejection. This covers the reported behavior as required by AGENTS.md's regression-evidence rule.

  • [needs fixing] packages/core/src/emdash-runtime.ts:4982-4988

    This docblock narrates the bug and summarizes the helper's implementation ("the calendar would … guess that the scheduler is down", "the entry is unscheduled and the error message is recorded"). AGENTS.md's Comments convention explicitly excludes PR-style narrative and restatements of what the code does. Please remove it; the helper name, refusal-code set, and calls already express this behavior. If a comment is needed, keep only a non-obvious invariant that a future maintainer could otherwise miss.

Add a VALIDATION_ERROR regression through runScheduledTasks(), name the
ScheduledPolicyRejection type change in the changeset, drop the helper's
narrative docblock, and stop calling the overflow entries "blocked".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RktNvhjty22ohh1GSHXGMq
@eisenbruch

Copy link
Copy Markdown
Contributor Author

Thanks, all four are in 7b7f4c0f2:

  • Changeset. It now says ScheduledPolicyRejection.pluginId is optional, that a refused-publish record carries code instead, and that code reading these records or narrowing with isScheduledPolicyRejection should handle a missing pluginId.
  • VALIDATION_ERROR regression. New test through runScheduledTasks(): a due post whose staged slug is blank is refused with "Cannot publish routable content without a slug". Its schedule is cleared, its draft revision is kept, and the record holds code: "VALIDATION_ERROR" and that message and passes isScheduledPolicyRejection. With VALIDATION_ERROR removed from the refusal codes, it fails.
  • Docblock. Removed from unscheduleRefusedPublish; the refusal-code constant keeps one line.
  • Overflow line. Now "One more entry is not shown." / "# more entries are not shown.", and the dashboard test follows.

Core unit tests: 512 files, 6,764 passed. Admin Dashboard.test.tsx: 28 passed. tsgo, oxlint and oxfmt clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RktNvhjty22ohh1GSHXGMq

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Routing permanent scheduled-publish refusals through the existing rejection flow remains the right approach and fits EmDash’s architecture.

All previous findings are addressed: the changeset explains the public record/type change and how consumers should adapt; the new real-database regression test exercises VALIDATION_ERROR through runScheduledTasks() and checks unscheduling, draft preservation, and the persisted reason; the narrative helper comment is removed; and the overflow wording matches the broader notice.

I reviewed the follow-up diff and traced the new test against the publication validation path. No new issues found. Review was static; checks were not run.


Follow-up review by openai/gpt-6.1-sol, scoped to the changes since the last review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RktNvhjty22ohh1GSHXGMq
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A scheduled publish that publishing refuses stays overdue, retries every pass, and the calendar blames the scheduler

1 participant