feat(routines): suppress scheduled ticks while project is paused (TON-2139) (#7502)
Fixes #7501 ## Thinking Path Issue checkout already rejects paused projects (`issues.ts` → 409), but that fires only *after* the routine execution issue exists. Per internal TON-1102, the pause must be honored earlier — at the scheduler tick — so no execution issue is ever created while a project is paused. The fix gates dispatch in `tickScheduledTriggers` on the due routine's `projects.pausedAt`, while preserving normal cron advancement so resume does not backfill missed firings. ## What Changed - `server/src/services/routines.ts`: - `tickScheduledTriggers` LEFT JOINs `projects` and derives `projectPaused` from `projects.pausedAt`. Routines with no project are never suppressed. - When paused: the tick is still claimed and `routineTriggers.nextRunAt` advances by a single cron step (catch-up backfill bypassed while paused — no replay on resume); `recordSuppressedScheduleRun` runs instead of `dispatchRoutineRun`. - New `recordSuppressedScheduleRun` inserts one `routine_runs` row (`source: schedule`, `status: skipped`, `failureReason: paused`, `linkedIssueId: null`, `completedAt` set), updates routine/trigger touched state, and logs a `routine.run_skipped` activity entry. - `nextResultText` gains a `skipped_paused` branch for pause-specific audit text (does not overload the live-issue `skipped` text). - `server/src/__tests__/routines-service.test.ts`: focused test proving all four acceptance criteria (no issue created while paused; one `skipped`/`paused`/null-link run; `nextRunAt` advanced; normal resume on a later unpaused tick). ## Verification ```bash pnpm --filter @paperclipai/server exec tsc --noEmit npx vitest run server/src/__tests__/routines-service.test.ts ``` - The focused test in `routines-service.test.ts` proves all four acceptance criteria: no execution issue is created while the project is paused; exactly one `routine_runs` row is written with `source: schedule` / `status: skipped` / `failureReason: paused` / `linkedIssueId: null`; the trigger's `nextRunAt` advances by a single cron step (missed firings are not backfilled); and a later tick after the project is unpaused dispatches normally. ## Risks - Catch-up backfill is intentionally bypassed while paused — missed firings are **not** replayed on resume (specified no-backfill behavior). - Scope is deliberately narrow: manual runs, webhook/API triggers, routine-level paused status, workspace runtime start/stop, `concurrencyPolicy`, and `catchUpPolicy` (beyond no-backfill) are untouched. ## Model Used claude-opus-4-8 (Paperclip CTO heartbeat) Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1514,4 +1514,88 @@ describeEmbeddedPostgres("routine service live-execution coalescing", () => {
|
||||
expect(run.source).toBe("webhook");
|
||||
expect(run.status).toBe("issue_created");
|
||||
});
|
||||
|
||||
it("suppresses scheduled ticks while the routine project is paused, then resumes when unpaused", async () => {
|
||||
const { companyId, projectId, routine, svc } = await seedFixture();
|
||||
const { trigger } = await svc.createTrigger(
|
||||
routine.id,
|
||||
{
|
||||
kind: "schedule",
|
||||
label: "daily",
|
||||
cronExpression: "0 0 * * *",
|
||||
timezone: "UTC",
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
const pastDue = new Date("2020-01-01T00:00:00.000Z");
|
||||
|
||||
// Pause the project and make the schedule trigger due.
|
||||
await db
|
||||
.update(projects)
|
||||
.set({ pausedAt: new Date(), pauseReason: "manual pause" })
|
||||
.where(eq(projects.id, projectId));
|
||||
await db
|
||||
.update(routineTriggers)
|
||||
.set({ nextRunAt: pastDue })
|
||||
.where(eq(routineTriggers.id, trigger.id));
|
||||
|
||||
const pausedResult = await svc.tickScheduledTriggers(new Date());
|
||||
expect(pausedResult.triggered).toBe(0);
|
||||
|
||||
// No execution issue should be created while paused.
|
||||
const issuesWhilePaused = await db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(eq(issues.companyId, companyId));
|
||||
expect(issuesWhilePaused).toHaveLength(0);
|
||||
|
||||
// One skipped routine run with pause-specific reason and no linked issue.
|
||||
const skippedRuns = await db
|
||||
.select()
|
||||
.from(routineRuns)
|
||||
.where(eq(routineRuns.routineId, routine.id));
|
||||
expect(skippedRuns).toHaveLength(1);
|
||||
expect(skippedRuns[0]?.status).toBe("skipped");
|
||||
expect(skippedRuns[0]?.source).toBe("schedule");
|
||||
expect(skippedRuns[0]?.failureReason).toBe("paused");
|
||||
expect(skippedRuns[0]?.linkedIssueId).toBeNull();
|
||||
expect(skippedRuns[0]?.completedAt).not.toBeNull();
|
||||
|
||||
// Trigger advanced past the paused firing and audit reflects the pause skip.
|
||||
const pausedTrigger = await db
|
||||
.select()
|
||||
.from(routineTriggers)
|
||||
.where(eq(routineTriggers.id, trigger.id))
|
||||
.then((rows) => rows[0]);
|
||||
expect(pausedTrigger?.nextRunAt).not.toBeNull();
|
||||
expect(pausedTrigger!.nextRunAt!.getTime()).toBeGreaterThan(pastDue.getTime());
|
||||
expect(pausedTrigger?.lastResult).toMatch(/paused/i);
|
||||
|
||||
// Unpause and make the trigger due again; a normal tick now creates an issue.
|
||||
await db
|
||||
.update(projects)
|
||||
.set({ pausedAt: null, pauseReason: null })
|
||||
.where(eq(projects.id, projectId));
|
||||
await db
|
||||
.update(routineTriggers)
|
||||
.set({ nextRunAt: pastDue })
|
||||
.where(eq(routineTriggers.id, trigger.id));
|
||||
|
||||
const resumedResult = await svc.tickScheduledTriggers(new Date());
|
||||
expect(resumedResult.triggered).toBe(1);
|
||||
|
||||
const issuesAfterResume = await db
|
||||
.select()
|
||||
.from(issues)
|
||||
.where(eq(issues.companyId, companyId));
|
||||
expect(issuesAfterResume).toHaveLength(1);
|
||||
|
||||
const runsAfterResume = await db
|
||||
.select()
|
||||
.from(routineRuns)
|
||||
.where(eq(routineRuns.routineId, routine.id));
|
||||
expect(runsAfterResume).toHaveLength(2);
|
||||
expect(runsAfterResume.some((run) => run.status === "issue_created")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user