Core Reducer
Project run events into materialized run state.
@runlane/core owns run lifecycle semantics. The low-level public primitive is projectRunEvents(), a pure reducer that turns append-only RunEvent values into the RunRecord projection storage adapters persist.
Most application code should never call this directly. Runtime APIs such as trigger(), runNow(), worker(), tick(), and runs.cancel() call it before they ask storage to append lifecycle events. Adapter authors need the contract because storage must persist the supplied events and supplied projection atomically without reimplementing status, counter, lease, retry, release, or cancellation rules.
Short example
The reducer can create a run from its first durable event:
import { asId, contractDefaults, RunEventType } from '@runlane/contracts'
import { projectRunEvents } from '@runlane/core'
const environment = { name: 'production' }
const runId = asId<'run'>('run_123')
const now = new Date()
const projectedRun = projectRunEvents({
expectedSequence: contractDefaults.run.newEventSequence,
events: [
{
actor: contractDefaults.actor.system,
environment,
occurredAt: now,
payload: { userId: 'user_123' },
queue: contractDefaults.queue,
runId,
taskId: asId<'task'>('emails.send'),
type: RunEventType.Created,
},
],
})The returned RunRecord has eventSequence: 1, zeroed counters, status: "queued", and the durable identity fields copied from the created event.
Appending later events starts from the current materialized run:
const updatedRun = projectRunEvents({
currentRun: projectedRun,
expectedSequence: projectedRun.eventSequence,
events: [
{
actor: contractDefaults.actor.system,
delivery: {
availableAt: now,
environment,
queue: contractDefaults.queue,
requestedAt: now,
runId,
},
environment,
occurredAt: now,
runId,
type: RunEventType.DeliveryRequested,
},
],
})Sequence contract
expectedSequence must match the current run sequence. Use contractDefaults.run.newEventSequence when creating a run and the current RunRecord.eventSequence when appending to an existing run. A stale sequence throws RunlaneError with ErrorCode.StorageConflict so callers can retry the read-project-append operation.
The reducer assigns projected sequences in memory as expectedSequence + event index + 1. Storage still owns durable event ids and must reject appends if its current sequence no longer matches the command.
Projection failures use two different error classes:
| Condition | Error |
|---|---|
expectedSequence is stale against currentRun.eventSequence | RunlaneError, ErrorCode.StorageConflict, retryable: true |
expectedSequence is negative, non-integer, or unsafe | RunlaneError, ErrorCode.InvariantViolation |
events is empty | RunlaneError, ErrorCode.InvariantViolation |
| Event order, status, lease, attempt, queue, run id, or environment is impossible | RunlaneError, ErrorCode.InvariantViolation |
Adapters should preserve this distinction. A stale sequence is a normal lost race. An impossible projection means core, storage, or persisted history is inconsistent.
Lifecycle rules
run.created is the only valid first event. It creates a queued run with zero attempts, failures, retries, and releases. The run projection copies durable identity and creation fields from this event: runId, environment, taskId, queue, payload, runAt, concurrencyKey, idempotencyKey, singletonKey, source, traceCarrier, meta, createdAt, and updatedAt.
Every later event must target the same runId and environment as the current projection. Terminal runs reject all later lifecycle events; rerun and manual retry create new source-linked runs instead of mutating terminal history.
| Event | Projection effect |
|---|---|
run.delivery_requested | Records durable wakeup intent. It can wake queued runs, due scheduled/retrying/released runs, or running runs whose lease has expired. It sets runAt to delivery.availableAt, clears the lease, and sets status to scheduled when availableAt is later than occurredAt; otherwise it sets status to queued. The delivery intent must target the same environment, run id, and queue as the run. |
run.lease_claimed | Moves runnable work to running, records the supplied lease, and clears dispatch reservation fields. Queued runs are runnable at runAt ?? updatedAt; scheduled, retrying, and released runs are runnable only once runAt is due; running runs are runnable again only after their lease expires. |
run.lease_heartbeat | Replaces the current lease while keeping the status and counters unchanged. The run must be running or cancellation_requested, and the heartbeat must use the current lease token and worker id. |
run.started | Starts the next attempt. The run must be running with an active lease, and event.attempt must equal counters.attempts + 1. The reducer increments attempts, clears any previous failure, and records startedAt. |
run.succeeded | Completes the current attempt and terminally sets status to succeeded. It clears lease and dispatch reservation fields, clears failure, and records finishedAt. |
run.failed | Completes the current attempt and terminally sets status to failed. It increments failures, stores failure, clears lease and dispatch reservation fields, and records finishedAt. |
run.retry_scheduled | Completes the current attempt as failure pressure without making the run terminal. It increments failures and retries, stores failure, clears lease and dispatch reservation fields, sets runAt to retryAt, and sets status to retrying. |
run.released | Completes the current attempt as business waiting. It increments releases, clears lease and dispatch reservation fields, sets runAt to resumeAt, and sets status to released without incrementing failures or retries. |
run.cancellation_requested | Marks a running leased run as cancellation_requested while keeping the lease. This lets cooperative cancellation reach the handler through its abort signal and then persist a terminal or waiting outcome. |
run.cancelled | Terminally cancels a queued, scheduled, retrying, released, or cancellation-requested run. Direct cancellation of a still-running run is rejected until cancellation has been requested. The reducer clears lease, dispatch reservation fields, and failure, then records finishedAt. |
Attempt completion events are run.succeeded, run.failed, run.retry_scheduled, and run.released. They require a running or cancellation_requested run, require at least one started attempt, and must reference the current attempt number. The reducer does not infer attempt numbers from event order.
Projection Fields
RunRecord is the materialized view of durable history. These fields have reducer-owned semantics:
| Field | Reducer rule |
|---|---|
eventSequence | Final sequence after applying all events in the command. |
status | Derived only from the event transition rules above. |
counters.attempts | Set by run.started; must advance by exactly one. |
counters.failures | Incremented by run.failed and run.retry_scheduled. |
counters.retries | Incremented only by run.retry_scheduled. |
counters.releases | Incremented only by run.released. |
runAt | Copied from run.created, then updated by delivery, retry, and release events. |
startedAt | Updated by the latest run.started. |
finishedAt | Set only by terminal success, failure, or cancellation. Retry and release leave it unset. |
failure | Set by failed and retry-scheduled outcomes; cleared by started, succeeded, and cancelled transitions. |
lease | Set by lease claim and heartbeat. Cleared when delivery recovery, terminal outcomes, retry, release, or cancellation move the run out of active execution. |
dispatchedAt / dispatchExpiresAt | Set only when a delivery request carries delivery.dispatchExpiresAt, which represents a bounded-queue dispatch reservation. Unbounded delivery requests clear both fields. |
source | Copied from run.created. Triggered, scheduled, rerun, and manual-retry lineage is not rewritten by later events. |
traceCarrier | Copied from run.created, then refreshed by delivery as delivery.traceCarrier ?? event.traceCarrier ?? currentRun.traceCarrier. |
meta | Copied from run.created. Later event meta remains in event history; it does not rewrite RunRecord.meta. |
The reducer does not create outbox rows. It only projects fields that storage and runtime code use when deciding whether outbox, dispatch, lease, and operator behavior is valid.
Adapter boundary
Adapters should treat the projected run as caller-supplied truth that must be checked, not silently trusted. A storage adapter should verify the command environment, run id, expected sequence, projected final sequence, and command-specific ownership before committing.
appendRunEvents() must compare the current persisted sequence with expectedSequence before validating projection invariants. If it does not match, reject with ErrorCode.StorageConflict and StorageConflictKind.EventSequence; do not report a stale append as a projection bug.
When an append succeeds, storage commits these records atomically:
- append-only event-history rows with storage-assigned ids and monotonic per-run sequences
- the supplied
projectedRun - storage-owned idempotency or singleton ownership updates
- outbox rows derived from any
run.delivery_requestedevents
reserveRunDispatch(), claimRunLease(), and heartbeatRunLease() also receive core-projected events and projected runs. Storage owns the atomic capacity, dispatch-reservation, lease-ownership, and sequence checks for those operations, but it should not recompute the materialized lifecycle projection.
The returned RunEventRecord values must be the exact rows persisted to history, including ids and sequence numbers. Do not generate one event id for storage and another for the API result.
See Adapter Authoring for the full storage contract and Run Lifecycle Vocabulary for stable status and event names.