Skip to content

fix(api): dispatch the terminal slot of bounded automations instead of silently dropping it - #6293

Closed
Kitenite wants to merge 1 commit into
mainfrom
debug/6128-scheduled-dispatch-loss
Closed

Kitenite wants to merge 1 commit into
mainfrom
debug/6128-scheduled-dispatch-loss

Conversation

@Kitenite

@Kitenite Kitenite commented Aug 9, 2026 •

Copy link
Copy Markdown
Member

Summary

Fixes #6128 — the terminal occurrence of every bounded (COUNT/UNTIL) automation was silently lost: evaluate flips enabled:false for an exhausted rule in the same tick that enqueues its final dispatch, the disable UPDATE beats QStash's HTTP delivery, and the dispatch handler bailed on enabled=false with a 200. Result: no run row, no workspaces.create, no retry/failure callback, and an automation that reads like a cleanly finished series. FREQ=DAILY;COUNT=1 could never fire. Run-now was unaffected because runNow calls dispatchAutomation directly with no enabled check.

Changes

  • evaluate computes the next occurrence before enqueueing and sets terminal: true on the payload when the rule is exhausted (unparseable rrules keep today's behavior: enqueue non-terminal + log the advance failure).
  • dispatch/[id] lets a disabled automation through when the payload is terminal and bucketToMinute(next_run_at) still equals scheduledFor — exhaustion leaves next_run_at at the terminal occurrence while every other transition moves it, so stale replays can't match.
  • Any delivery still dropped on the disabled guard now persists a dispatch_failed run row (automation disabled before dispatch delivery) instead of vanishing.

Verification

  • Unit: 7 route tests (mocked QStash/db), mutation-tested — reverting the guard or the flag makes exactly the terminal-slot tests fail.
  • E2E (local QStash dev server + next dev + isolated Neon branch, real enqueue→signed delivery):
    • pre-fix COUNT=1: handler hit, 200 skipped:"disabled", 0 run rows, enabled:false — bug reproduced;
    • post-fix COUNT=1: terminal slot persists a run row, then clean disable;
    • post-fix COUNT=2 (MINUTELY, two ticks): both slots persist rows incl. the terminal one;
    • late delivery to a paused automation: dispatch_failed row persisted.
  • bun run lint and bun run typecheck exit 0.

Not exercised e2e: relay→workspaces.create happy path (no live host locally); that's dispatchAutomation, shared byte-for-byte with the proven manual path and unchanged here.

Remaining product decisions (status enum instead of overloading enabled, retry/backfill for missed slots, relay presence accuracy #6014/#6160) are written up in plans/6128-scheduled-dispatch-loss.md.


Summary by cubic

Fixes #6128 by ensuring the terminal slot of bounded (COUNT/UNTIL) automations is dispatched instead of being dropped, and by recording a run when a disabled guard stops delivery. This restores expected behavior for cases like FREQ=DAILY;COUNT=1.

  • Bug Fixes
    • Evaluate: compute the next occurrence before enqueueing and send terminal: true when the rule is exhausted; unparseable rrules still enqueue with terminal: false and log the advance failure. Uses @upstash/qstash batch enqueue.
    • Dispatch: allow a disabled automation through when the payload is terminal and bucketToMinute(next_run_at) equals scheduledFor; otherwise persist a dispatch_failed run row (“automation disabled before dispatch delivery”) instead of silently dropping. Calls @superset/trpc/automation-dispatch.

Written for commit 73d281c. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • Bug Fixes

    • Prevented the final scheduled occurrence of bounded automations from being dropped when the automation becomes disabled.
    • Recorded skipped deliveries from disabled automations for improved run history and visibility.
    • Improved handling of stale or invalid scheduled deliveries.
  • Documentation

    • Added documentation covering the scheduled-dispatch race, resolution, testing, and known limitations.

…f silently dropping it

evaluate disables an exhausted COUNT/UNTIL rule in the same tick that
enqueues its final occurrence, and that UPDATE reliably lands before
QStash delivers the dispatch. The dispatch handler then bailed on
enabled=false with a 200, so the final slot of every bounded automation
vanished: no run row, no workspaces.create, no retry, no failure
callback — indistinguishable from a cleanly finished series.
FREQ=DAILY;COUNT=1 could never fire at all.

- evaluate now computes the next occurrence before enqueueing and marks
  the payload terminal when the rule is exhausted
- dispatch lets a terminal payload through the disabled guard when its
  slot still matches next_run_at (stale replays can't match)
- any delivery still dropped on the disabled guard persists a
  dispatch_failed run row instead of disappearing

Fixes #6128
@coderabbitai

coderabbitai Bot commented Aug 9, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The evaluator now marks exhausted bounded-rule occurrences as terminal before enqueueing them. The dispatch route permits matching terminal deliveries after disablement and records other disabled deliveries as failed runs. Tests cover terminal, non-terminal, invalid, stale, and disabled cases.

Changes

Bounded automation dispatch

Layer / File(s) Summary
Terminal occurrence planning
apps/api/src/app/api/automations/evaluate/route.ts, apps/api/src/app/api/automations/evaluate/route.test.ts
The evaluator precomputes occurrences, sends terminal metadata for exhausted bounded rules, advances schedules with Promise.allSettled, and reports calculation failures. Tests cover terminal, non-terminal, and invalid rules.
Terminal delivery and disabled-slot recording
apps/api/src/app/api/automations/dispatch/[id]/route.ts, apps/api/src/app/api/automations/dispatch/[id]/route.test.ts, plans/6128-scheduled-dispatch-loss.md
The dispatch route matches terminal payloads to nextRunAt, dispatches valid terminal deliveries, records skipped disabled deliveries as dispatch_failed runs, and ignores duplicate slots. Tests and issue documentation cover the resulting behavior.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant EvaluatorRoute
  participant AutomationDatabase
  participant QStash
  participant DispatchRoute
  EvaluatorRoute->>AutomationDatabase: read due automations
  EvaluatorRoute->>EvaluatorRoute: compute occurrence and terminal status
  EvaluatorRoute->>AutomationDatabase: disable exhausted bounded automation
  EvaluatorRoute->>QStash: enqueue payload with terminal flag
  QStash->>DispatchRoute: deliver scheduled payload
  DispatchRoute->>AutomationDatabase: read automation and nextRunAt
  DispatchRoute->>DispatchRoute: match terminal slot or record disabled skip
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title uses conventional commit format and clearly describes the terminal-slot dispatch fix for bounded automations.
Description check ✅ Passed The description explains the problem, changes, testing, issue link, and limitations, but omits the repository checklist section.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch debug/6128-scheduled-dispatch-loss

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@plans/6128-scheduled-dispatch-loss.md`:
- Line 1: Move the shipped plan document 6128-scheduled-dispatch-loss.md from
plans/ to plans/done/, preserving its filename and contents.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 1b94e989-2788-4e17-81a4-9890a4458414

📥 Commits

Reviewing files that changed from the base of the PR and between f231665 and 73d281c.

📒 Files selected for processing (5)
  • apps/api/src/app/api/automations/dispatch/[id]/route.test.ts
  • apps/api/src/app/api/automations/dispatch/[id]/route.ts
  • apps/api/src/app/api/automations/evaluate/route.test.ts
  • apps/api/src/app/api/automations/evaluate/route.ts
  • plans/6128-scheduled-dispatch-loss.md

@@ -0,0 +1,77 @@
# #6128: scheduled dispatch silently lost on the terminal slot of bounded rules

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.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Move this shipped plan to plans/done/.

This document states that the fix shipped in this branch. Move it to plans/done/6128-scheduled-dispatch-loss.md.

As per coding guidelines, “move shipped plans to plans/done/.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plans/6128-scheduled-dispatch-loss.md` at line 1, Move the shipped plan
document 6128-scheduled-dispatch-loss.md from plans/ to plans/done/, preserving
its filename and contents.

Source: Coding guidelines

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

🚀 Preview Deployment

🔗 Preview Links

Service Status Link
Neon Database (Neon) ✅ View Branch
Vercel API (Vercel) ✅ Open Preview
Vercel Web (Vercel) ✅ Open Preview
Vercel Marketing (Vercel) ✅ Open Preview
Vercel Admin (Vercel) ✅ Open Preview
Vercel Docs (Vercel) ✅ Open Preview

Preview updates automatically with new commits

@cubic-dev-ai cubic-dev-ai 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.

4 issues found across 5 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/api/src/app/api/automations/dispatch/[id]/route.ts">

<violation number="1" location="apps/api/src/app/api/automations/dispatch/[id]/route.ts:82">
P1: Pausing a bounded automation after its final job is enqueued can still launch that job, because user-driven disable leaves `nextRunAt` at the matching terminal slot. Persist distinct exhaustion state or invalidate the terminal slot when disabling so this bypass applies only to evaluate’s automatic disable.</violation>
</file>

<file name="apps/api/src/app/api/automations/dispatch/[id]/route.test.ts">

<violation number="1" location="apps/api/src/app/api/automations/dispatch/[id]/route.test.ts:14">
P3: The QStash signature gate is hard-mocked to `async () => true`, so the route's 401 security paths (missing signature, verify() returning false) and the 400 invalid-payload path have zero coverage. Since this endpoint's entire trust model is signature verification, add a test that makes the verify mock resolve `false` (and one for a missing header) asserting a 401 and that `dispatchAutomation` is not called.</violation>

<violation number="2" location="apps/api/src/app/api/automations/dispatch/[id]/route.test.ts:126">
P3: The stale-terminal-replay test only asserts `insertedRuns` has length 1, not that the row is `dispatch_failed` with the expected `scheduledFor`/`error`. A regression that persisted a `dispatched`, `running`, or wrong-slot row would still pass this test. Assert the row content like the disabled-skip test does.</violation>
</file>

<file name="plans/6128-scheduled-dispatch-loss.md">

<violation number="1" location="plans/6128-scheduled-dispatch-loss.md:34">
P3: The stated safety rationale is inaccurate: pausing an automation does not move next_run_at. In setEnabled (packages/trpc/src/router/automation/automation.ts) the nextRunAt patch is applied only on resume; disabling an already-exhausted automation leaves next_run_at at the terminal occurrence, so a stale terminal replay still passes the bucketToMinute(nextRunAt)==scheduledFor guard and re-runs dispatchAutomation. The real protection against double-dispatch is the run-row uniqueness constraint (onConflictDoNothing), not this guard as documented — correct the rationale so maintainers don't rely on the guard alone.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant Cron as Scheduler (cron)
    participant Ev as EvaluateRoute
    participant DB as Database
    participant QS as QStash
    participant Dp as DispatchRoute
    participant DA as dispatchAutomation (TRPC)
    participant Relay as Relay (external)

    Note over Ev,DB: Evaluate tick: fetch due automations
    Cron->>Ev: POST /api/automations/evaluate
    Ev->>DB: Fetch due automations (nextRunAt <= now)
    DB-->>Ev: Due automation rows

    loop Process each due automation
        Ev->>Ev: Compute next occurrence (try/catch)
        alt computeError (unparseable rrule)
            Ev->>QS: Enqueue job with terminal=false (NEW: always non-terminal)
            Ev->>Ev: Log advance failure (no DB update)
        else next === null (exhausted bounded rule)
            Ev->>QS: Enqueue job with terminal=true (NEW: terminal flag)
            Ev->>DB: Update automation set enabled=false (CHANGED: still disables, but terminal flag communicates exhaustion)
        else next exists (normal advance)
            Ev->>QS: Enqueue job with terminal=false (non-terminal)
            Ev->>DB: Update automation set nextRunAt=next
        end
    end

    Note over QS,Dp: QStash delivers scheduled job (may be before or after DB update)
    QS->>Dp: POST /api/automations/dispatch/{id} (payload: terminal, scheduledFor)
    Dp->>DB: Load automation by id
    DB-->>Dp: Automation row (enabled, nextRunAt)

    alt automation.enabled
        Dp->>DA: dispatchAutomation(automation, scheduledFor)
        DA->>Relay: Trigger workspace create (part of automation)
        DA->>DB: Insert run row (success)
    else automation disabled
        alt terminal == true AND bucketToMinute(nextRunAt) == scheduledFor
            Note over Dp,DA: NEW: terminal slot matches next_run_at → exhaustion, not user pause
            Dp->>DA: dispatchAutomation(automation, scheduledFor) (CHANGED: allow even if disabled)
            DA->>Relay: Trigger workspace create
            DA->>DB: Insert run row (success)
        else
            Note over Dp,DB: NEW: drop on disabled guard now records a failed run
            Dp->>DB: Insert dispatch_failed run row ("automation disabled before dispatch delivery")
            Dp-->>QS: Return 200 {ok:true, skipped:"disabled"}
        end
    end
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

// next_run_at moves on any other transition (advance, pause/resume
// recompute), so a stale terminal replay can't match.
const isTerminalSlot =
parsed.data.terminal &&

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.

P1: Pausing a bounded automation after its final job is enqueued can still launch that job, because user-driven disable leaves nextRunAt at the matching terminal slot. Persist distinct exhaustion state or invalidate the terminal slot when disabling so this bypass applies only to evaluate’s automatic disable.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/app/api/automations/dispatch/[id]/route.ts, line 82:

<comment>Pausing a bounded automation after its final job is enqueued can still launch that job, because user-driven disable leaves `nextRunAt` at the matching terminal slot. Persist distinct exhaustion state or invalidate the terminal slot when disabling so this bypass applies only to evaluate’s automatic disable.</comment>

<file context>
@@ -55,15 +69,54 @@ export async function POST(
+		// next_run_at moves on any other transition (advance, pause/resume
+		// recompute), so a stale terminal replay can't match.
+		const isTerminalSlot =
+			parsed.data.terminal &&
+			bucketToMinute(automation.nextRunAt).getTime() === scheduledFor.getTime();
+		if (!isTerminalSlot) {
</file context>


mock.module("@upstash/qstash", () => ({
Receiver: class {
verify = mock(async () => true);

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.

P3: The QStash signature gate is hard-mocked to async () => true, so the route's 401 security paths (missing signature, verify() returning false) and the 400 invalid-payload path have zero coverage. Since this endpoint's entire trust model is signature verification, add a test that makes the verify mock resolve false (and one for a missing header) asserting a 401 and that dispatchAutomation is not called.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/app/api/automations/dispatch/[id]/route.test.ts, line 14:

<comment>The QStash signature gate is hard-mocked to `async () => true`, so the route's 401 security paths (missing signature, verify() returning false) and the 400 invalid-payload path have zero coverage. Since this endpoint's entire trust model is signature verification, add a test that makes the verify mock resolve `false` (and one for a missing header) asserting a 401 and that `dispatchAutomation` is not called.</comment>

<file context>
@@ -0,0 +1,152 @@
+
+mock.module("@upstash/qstash", () => ({
+	Receiver: class {
+		verify = mock(async () => true);
+	},
+}));
</file context>

const json = (await response.json()) as { skipped?: string };
expect(json.skipped).toBe("disabled");
expect(dispatchAutomation).not.toHaveBeenCalled();
expect(insertedRuns).toHaveLength(1);

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.

P3: The stale-terminal-replay test only asserts insertedRuns has length 1, not that the row is dispatch_failed with the expected scheduledFor/error. A regression that persisted a dispatched, running, or wrong-slot row would still pass this test. Assert the row content like the disabled-skip test does.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/app/api/automations/dispatch/[id]/route.test.ts, line 126:

<comment>The stale-terminal-replay test only asserts `insertedRuns` has length 1, not that the row is `dispatch_failed` with the expected `scheduledFor`/`error`. A regression that persisted a `dispatched`, `running`, or wrong-slot row would still pass this test. Assert the row content like the disabled-skip test does.</comment>

<file context>
@@ -0,0 +1,152 @@
+		const json = (await response.json()) as { skipped?: string };
+		expect(json.skipped).toBe("disabled");
+		expect(dispatchAutomation).not.toHaveBeenCalled();
+		expect(insertedRuns).toHaveLength(1);
+		expect(insertedRuns[0]).toMatchObject({
+			automationId: AUTOMATION_ID,
</file context>

- `dispatch` lets a disabled automation through when the payload is terminal
AND `bucketToMinute(next_run_at)` still equals `scheduledFor` (the disable
path leaves `next_run_at` at the terminal occurrence; any other transition
moves it, so stale replays can't match).

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.

P3: The stated safety rationale is inaccurate: pausing an automation does not move next_run_at. In setEnabled (packages/trpc/src/router/automation/automation.ts) the nextRunAt patch is applied only on resume; disabling an already-exhausted automation leaves next_run_at at the terminal occurrence, so a stale terminal replay still passes the bucketToMinute(nextRunAt)==scheduledFor guard and re-runs dispatchAutomation. The real protection against double-dispatch is the run-row uniqueness constraint (onConflictDoNothing), not this guard as documented — correct the rationale so maintainers don't rely on the guard alone.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At plans/6128-scheduled-dispatch-loss.md, line 34:

<comment>The stated safety rationale is inaccurate: pausing an automation does not move next_run_at. In setEnabled (packages/trpc/src/router/automation/automation.ts) the nextRunAt patch is applied only on resume; disabling an already-exhausted automation leaves next_run_at at the terminal occurrence, so a stale terminal replay still passes the bucketToMinute(nextRunAt)==scheduledFor guard and re-runs dispatchAutomation. The real protection against double-dispatch is the run-row uniqueness constraint (onConflictDoNothing), not this guard as documented — correct the rationale so maintainers don't rely on the guard alone.</comment>

<file context>
@@ -0,0 +1,77 @@
+- `dispatch` lets a disabled automation through when the payload is terminal
+  AND `bucketToMinute(next_run_at)` still equals `scheduledFor` (the disable
+  path leaves `next_run_at` at the terminal occurrence; any other transition
+  moves it, so stale replays can't match).
+- Any delivery still dropped on the disabled guard now persists a
+  `dispatch_failed` run row (`automation disabled before dispatch delivery`)
</file context>

@github-actions

github-actions Bot commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

This PR has had no activity for 53 days and will close in 7 days unless there is new activity. If this contribution is still important or relevant, please leave a comment to keep it open. If it closes, you are welcome to reopen it or ask a maintainer to reopen it. Thank you for contributing!

@github-actions github-actions Bot added the stale label Oct 2, 2026
@github-actions

Copy link
Copy Markdown
Contributor

This PR was automatically closed due to inactivity to help keep the review queue manageable. This does not mean the contribution was rejected. If this contribution is still important or relevant, please reopen it or leave a comment asking a maintainer to reopen it. Thank you for contributing!

@github-actions github-actions Bot closed this Oct 13, 2026

This branch was previously deployed

1 inactive deployment
preview — 73d281c6 Deployed Aug 9, 2026 by Kitenite via Deploy Marketing #7320
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Scheduled automation dispatch silently lost while target host is verifiably online; manual 'automations run' succeeds minutes later

1 participant