Scheduled Work System – End-to-End Guide
This document explains how our automated work refresh feature works. It covers backend architecture, batch dispatching, Trigger.dev integration, subscription toggles, environment variables, and the frontend experience. Treat it as the single source of truth for anyone touching scheduled updates.
1. High-Level Overview
- A user manually generates a work and saves a prompt/config (
config.metadata.last_request_dataandconfig.metadata.last_request_data). - Once that metadata exists, the schedule UI becomes available and the backend accepts schedule API calls.
- The user chooses cadence, billing mode, and safeguards. The backend validates those choices against plan limits or pay-per-use rules.
WorkScheduleServicepersists schedule information and mirrors denormalized data onto theWorkentity (so dashboards can render state quickly).WorkScheduleDispatcherServiceruns on a cron-like cadence (Trigger.dev task or Nest fallback). Each run fetches due schedules in batches and enqueues generation runs.WorkGenerationService.runScheduledUpdate()reuses the same pipeline as manual runs, pullinglast_request_dataand invoking Trigger.dev or in-process fallbacks.- After each run finishes, the schedule record is updated with the latest status, timestamps, and failure counts. Usage ledger entries are written if pay-per-use billing was requested.
2. Backend Architecture
Core Entities
- WorkSchedule – stores cadence, status, billing mode, next/last run timestamps, failure counters, etc.
- Work (denormalized fields) –
scheduledUpdatesEnabled,scheduledCadence,scheduledNextRunAt,scheduledStatuskeep the dashboard fast. - SubscriptionPlan / UserSubscription – define cadence allowances and plan metadata when subscriptions are turned on.
- UsageLedgerEntry – optional row per pay-per-use run when subscriptions enforce cadence limits.
Key Services
-
WorkScheduleService- Ensures the caller owns the work.
- Calls
DataGeneratorService.config()to guaranteemetadata.last_request_dataexists. - Validates cadences and billing modes against plan limits (or forces usage billing).
- Caps failure thresholds.
- Mirrors schedule state back to the
Worktable. - Exports DTOs for API/UI consumption (includes
subscriptionsEnabled). - Validates run entitlement at runtime (pauses schedules if plan limits are exceeded).
- Recovers stuck schedules (marks "zombies" as failed if stuck in
GENERATING> 1 hour).
-
WorkScheduleDispatcherService- Runs cleanup for stuck schedules (
recoverStuckSchedules) before dispatching. - Reads
findDue(limit)from the repository, wherelimit = SCHEDULED_UPDATES_MAX_BATCH. - For each result, calls
markRunDispatched()(atomic update that clearsnextRunAtand sets status to "generating" to prevent duplicate dispatch). - Invokes
WorkGenerationService.runScheduledUpdate()for each reserved schedule.
- Runs cleanup for stuck schedules (
-
WorkGenerationService- Reuses last request data from the work config (handles stale config gracefully by pausing schedule).
- Creates a new history entry with
triggeredBy='schedule'. - Dispatches the Trigger.dev task or performs in-process generation if Trigger.dev is disabled/unavailable.
- Sequential Fallback: When Trigger.dev is unavailable, scheduled runs execute sequentially in-process to prevent resource exhaustion.
- Calls back into
WorkScheduleService.markRunCompleted()or.markRunFailed()when the run finishes. - Drift Prevention: Calculates next run time based on the scheduled time, not completion time.
Trigger.dev + Fallback Cron
- Primary mode:
packages/tasks/src/tasks/trigger/work-schedule-dispatcher.task.tsregisters a Trigger.dev cron task (default every 5 minutes). The task boots a Nest context, resolvesWorkScheduleDispatcherService, and processes the batch. - Fallback mode (when
TRIGGER_ENABLED=false): Nest's cron scheduler calls the same dispatcher service. Config is identical; only the triggering mechanism changes.
3. Batch Dispatch Flow
recoverStuckSchedules()identifies rows stuck inGENERATINGfor > 1 hour and marks them failed to prevent "zombies".findDue(limit)retrieves active schedules withnextRunAt <= now().- If there are more due rows than the batch size, the dispatcher simply processes
limititems this tick. The remaining rows are picked up on the next cron run—this prevents sending 1,000 runs at once. markRunDispatched(scheduleId)uses aWHERE status=ACTIVE AND nextRunAt IS NOT NULLclause to atomically reserve a row. If two dispatchers race, only one succeeds and the other drops the row from this cycle.runScheduledUpdate()loads the fullWorkandUser, validates subscription limits (pausing if exceeded), fetcheslast_request_data, and callsupdateItemsGenerator().- Completion/failure paths set
nextRunAt(using the original scheduled time as an anchor to prevent drift),lastRunAt,lastRunStatus, reset/increment failure counters, and optionally pause the schedule whenfailureCount >= maxFailureBeforePause.
4. Subscription On/Off Behavior
Flag: SUBSCRIPTIONS_ENABLED
-
Located in
apps/api/.env.exampleandpackages/agent/src/config/index.ts. -
When true:
SubscriptionServiceresolves the user plan (default fromSUBSCRIPTIONS_DEFAULT_PLANif none stored).WorkScheduleServiceenforcesplan.maxWorks.- Cadences outside the allowed list require
billingMode='usage', otherwise the API returns a 400 with an upgrade hint. UsageLedgerService.recordUsage()writes ledger entries and notifies the billing provider when scheduled runs use pay-per-use.- The web UI displays plan names, billing selectors, pay-per-use toggles, and upgrade hints.
-
When false:
SubscriptionServiceshort-circuits and returns a synthetic "free/unmetered" plan where every cadence is allowed.WorkScheduleServiceskips work-count limits and never forces pay-per-use.UsageLedgerServicebecomes a no-op.WorkScheduleDto.planCodeis omitted, and the UI hides billing controls.- API endpoints that change plans (
/api/subscriptions/plan) throwSubscriptions are disabled.
Additional Subscription-Related Env Vars (apps/api/.env.example):
| Variable | Purpose |
|---|---|
SUBSCRIPTIONS_ENABLED | Master flag for enabling billing/subscription enforcement. |
SUBSCRIPTIONS_DEFAULT_PLAN | Plan code used when the user has no explicit subscription. |
BILLING_DEFAULT_CURRENCY | Currency stored on plan/ledger rows (default usd). |
PAY_PER_USE_PRICE_USD | Price used when billing per run. Converted to cents in config. |
STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET | Reserved for future Stripe integration (currently unused stubs). |
5. Scheduled Updates Env Vars
Located in apps/api/.env.example and consumed by packages/agent/src/config/index.ts:
| Variable | Effect |
|---|---|
SCHEDULED_UPDATES_ENABLED | Global kill switch to disable the entire scheduling feature (UI + backend). |
SCHEDULED_UPDATES_DISPATCH_INTERVAL_MINUTES | Cron interval for dispatcher when Trigger.dev is enabled (default 5). |
SCHEDULED_UPDATES_MAX_BATCH | Number of schedules processed per dispatcher run (default 25). |
SCHEDULED_UPDATES_MAX_FAILURE_BEFORE_PAUSE | Default failure threshold before auto-pausing (user can override 1–10). |
6. Frontend Flow (apps/web)
WorkLayoutfetches/works/:id+/works/:id/config. Ifconfig.metadata.last_request_datais missing, the schedule tab is hidden and/scheduleroutes return 404.WorkSchedulePagefetches/works/:id/schedule. If the API fails (no schedule yet), the card renders an empty state prompting a manual refresh.WorkScheduleCarddisplays:- Summary chips (status, next run, last run, failures).
- Automations toggle.
- Billing selector (only if subscriptions enabled).
- Cadence dropdown + pay-per-use helper pill.
- Failure threshold input.
Run now,Save,Cancelbuttons with server actions.
- Client actions (
updateWorkSchedule,runWorkSchedule,cancelWorkSchedule) call server routes which wrap the API. Success/failure toasts come fromsonner.
7. Trigger.dev Task Summary
- File:
packages/tasks/src/tasks/trigger/work-schedule-dispatcher.task.ts - Trigger:
onSchedule({ cron: '*/5 * * * *' })(configurable via env). - Workflow:
- Boot Nest application context.
- Resolve
WorkScheduleDispatcherService. - Execute
dispatchDue()withMAX_BATCH. - Report metrics/logging for monitoring.
- Fallback: When Trigger.dev is unavailable, Nest's own scheduler (enabled via a configuration flag) calls the same service to preserve behavior.
8. Failure Handling and Auto-Pause
- Every run increments
failureCountwhen it ends withGenerateStatusType.ERROR. maxFailureBeforePausedefaults toSCHEDULED_UPDATES_MAX_FAILURE_BEFORE_PAUSEbut can be set per work (bounded 1–10).- When the limit is reached:
- The schedule is automatically paused (
status=PAUSED). nextRunAtis cleared.- The UI shows the paused state and instructs the user to investigate errors.
- If the user re-enables later, failure count resets.
- The schedule is automatically paused (
- If a failure happens but the limit isn’t reached,
nextRunAtis bumped forward (default 15-minute delay before the next attempt).
9. Usage Ledger Rules
- Only active when subscriptions are enabled and the schedule’s
billingMode === 'usage'. - Each run records:
userId,workId,scheduleIdtriggerType='scheduled'units=1amountCents = PAY_PER_USE_PRICE_USD * 100metadata.cadence
- Ledger entries are sent to
BillingProvider(currently a manual stub). Future Stripe integration will listen for these entries to create invoices.
10. Initial Prompt Requirement
The entire system depends on config.metadata.last_request_data and config.metadata.last_request_data. We enforce this in two places:
- UI:
WorkLayoutonly enables the schedule tab when the config contains an initial prompt. Otherwise,/scheduleroutes return 404. - Backend:
WorkScheduleService.ensureWorkConfigReady()loads the config usingDataGeneratorService.config()for everygetScheduleandupdateSchedulecall. If the prompt is missing, it throws a400(“Complete an initial work setup before enabling scheduled updates.”).
This guard ensures scheduled runs always have the context needed to regenerate content.
11. Quick Reference
| Component | File/Location | Notes |
|---|---|---|
| API endpoints | apps/api/src/works/works.controller.ts (/schedule routes) | All authenticated via JWT guard. |
| Schedule service | packages/agent/src/services/work-schedule.service.ts | Contains validation, DTO mapping, and work syncing. |
| Dispatcher | packages/agent/src/services/work-schedule-dispatcher.service.ts | Handles batching and locking. |
| Trigger task | packages/tasks/src/tasks/trigger/work-schedule-dispatcher.task.ts | Cron-based dispatcher runner. |
| Config | packages/agent/src/config/index.ts | Exposes typed accessors for env vars, including subscription toggles. |
| Frontend card | apps/web/src/components/works/detail/WorkScheduleCard.tsx | Client-side UI and server actions. |
| Env reference | apps/api/.env.example | Contains every variable described above. |
12. Frequently Asked Questions
Q: What happens when many schedules are due but SCHEDULED_UPDATES_MAX_BATCH is small?
A: Each dispatcher run processes only MAX_BATCH rows. Remaining rows stay due and will be picked up on the next tick (default every 5 minutes). Adjust batch size or interval if you need faster throughput.
Q: Can scheduling run without Trigger.dev?
A: Yes. If TRIGGER_ENABLED=false, our Nest cron fallback kicks in, using the same dispatcher service. Manual runs also work because WorkGenerationService falls back to in-process execution when Trigger.dev dispatch fails.
Q: How do we re-enable subscriptions later?
A: Flip SUBSCRIPTIONS_ENABLED=true. The backend instantly enforces plan limits; the UI automatically re-renders billing controls because the DTO includes subscriptionsEnabled=true. Existing schedules keep their cadence, but users will see pay-per-use messaging if they exceed plan allowances.
Q: Why do I sometimes see “Complete an initial work setup before enabling scheduled updates”?
A: The work’s config repository doesn’t contain metadata.last_request_data yet. Run a manual generation (or supply the prompt), then refresh the schedule page.
This guide should eliminate guesswork while maintaining the flexibility to evolve the system. If you extend scheduling or billing behavior, update this document so future collaborators stay aligned.***