TramAI

Workflow Scheduling

tramai-scheduler adds durable time-based execution to explicit TramAI workflows. It sits above tramai-orchestration — it decides when a workflow run should start or resume.


What This Covers

  • Cron-backed workflow schedules
  • Timezone-aware execution
  • Skip calendars and business-hours filters
  • Delay-step wakeups for suspended workflows
  • Durable tick claiming through WorkflowSchedulerStore
  • Misfire handling and observer events

When to Add It

Add tramai-scheduler when:

  • a workflow must run on a clock rather than from application code
  • a delayed workflow must resume after a persisted timer
  • you need schedule state in JDBC instead of an external cron wrapper

If you only need workflow.run(...) from application code, stay with tramai-orchestration.


Minimum Setup

Dependency

implementation("dev.tramai:tramai-scheduler:0.3.1")
implementation("dev.tramai:tramai-orchestration:0.3.1")

Basic Schedule

import dev.tramai.scheduler.at
import dev.tramai.scheduler.dailyAt
import dev.tramai.scheduler.every
import java.time.temporal.ChronoUnit

// Cron expression: "minute hour day-of-month month day-of-week"
val schedule = at(
    expression = "0 9 * * 1", // 9 AM every Monday
    zone = "Europe/Rome",
)

// Daily at fixed time
val daily = dailyAt(
    hour = 9,
    minute = 30,
    zone = "Europe/Rome",
)

// Simple interval
val fiveMinutes = every(5, ChronoUnit.MINUTES, zone = "UTC")

Schedule DSL

at(...)

Use when you already know the cron expression. Supports:

  • five-field: minute, hour, day-of-month, month, day-of-week
  • six-field: + seconds as first field
  • ZoneId or zone string overloads

dailyAt(...)

For one fixed local time each day.

every(...)

For simple recurring intervals. Supported units: SECONDS, MINUTES, HOURS, DAYS.

Caveat: every(N, DAYS) uses cron day-of-month stepping — it resets at month boundaries, not a true continuous timer.


Calendar and Business-Hour Policies

Skip Calendar

Skip specific dates:

import dev.tramai.scheduler.FixedDate

val schedule = dailyAt(
    hour = 9,
    minute = 0,
    zone = "Europe/Rome",
    skipCalendar = listOf(FixedDate(12, 25)), // skip Christmas
)

Business Hours Only

Run only during business hours:

val weekdayMorning = dailyAt(
    hour = 9,
    minute = 0,
    zone = "Europe/Rome",
    skipCalendar = listOf(FixedDate(12, 25)),
    businessHoursOnly = true,
)

Current behavior: When a cron fire lands outside business hours, the scheduler returns the next business-hour start directly. That adjusted time is not revalidated against the cron expression.


Delay Steps

Scheduling enables delayStep(...) across process boundaries:

workflow<MyState>("invoice-follow-up") {
    localStep("prepare") { state, _ -> state.copy(prepared = true) }
    delayStep("wait-15-minutes", duration = 15, unit = TimeUnit.MINUTES)
    aiStep(
        name = "follow-up",
        input = { state -> state.prompt },
        invoke = followUpService::send,
        merge = { state, result -> state.copy(result = result) },
    )
}

Runtime behavior:

  1. the workflow checkpoints itself and throws WorkflowSuspendedException
  2. the scheduler store records a delay wakeup
  3. a later scheduler poll resumes the workflow from persisted state

Timer Model

ScheduledWorkflowTimer is the runtime entry point. It:

  • registers workflows with a declared schedule
  • writes schedule state to WorkflowSchedulerStore
  • polls for due ticks
  • claims work with fencing tokens
  • starts new workflow runs
  • resumes delayed runs when wakeups mature

Persistence Model

Two levels:

  1. Workflow state persistence from tramai-orchestration — checkpoints, leases
  2. Schedule/tick persistence from tramai-scheduler — schedule ticks, delay wakeups

Typical durable setup:

// From tramai-orchestration
val persistence = WorkflowPersistence(
    checkpointStore = JdbcWorkflowCheckpointStore(dataSource),
    stateCodec = JacksonStateCodec(),
)

// From tramai-scheduler
val store = JdbcWorkflowSchedulerStore(dataSource)

The scheduler does not hide those boundaries — you choose the persistence strategy explicitly.


Misfire Handling

The scheduler compares scheduled fire time to misfireThreshold.

Default settings in ScheduledWorkflowTimer:

SettingDefault
Poll interval1 second
Claim duration30 seconds
Misfire threshold5 minutes
Batch size50

When a claimed tick exceeds the misfire threshold, the timer marks it as misfired and emits observer callbacks instead of running the workflow.


Observer Surface

Scheduler hooks in the workflow observer:

  • onScheduledTick(...) — a tick was claimed and will execute
  • onSkippedTick(...) — a tick was skipped (calendar, business hours)
  • onMissedTick(...) — a tick was misfired

This keeps scheduling observable without forcing an OpenTelemetry dependency.


Limitations

  • does not provide distributed cron coordination across multiple scheduler nodes
  • currently supports cron schedules only
  • no runtime editing of workflow definitions
  • no true continuous every-N-days semantics (resets at month boundaries)

Next Steps