# Queues, cron schedules, and cancellation for AI agents

To run agent workflows on a durable queue with Tidebase, enqueue them — dedupe keys, delays, retries with backoff, and per-queue concurrency caps are built in, and a queued job IS a run, so its status lives in the same authoritative lifecycle as everything else.

```typescript
// enqueue: at most one active run per dedupe key, 3 attempts with backoff
await tide.enqueue('generate-report', {
  queue: 'reports',
  input: { topic },
  dedupeKey: `report:${topic}`,
  maxAttempts: 3,
  deadlineMs: 600_000
})

// pull-mode worker: claims ready runs, executes registered workflows
tide.workflow('generate-report', generateReport)
await tide.work({ queues: ['reports'] })
```

Tidebase is an open-source checkpoint layer for AI agents: wrap your steps, and failed runs resume from the last safe point — in your own Postgres, without moving execution into a new runtime. Since v0.5 it can also decide **when** your code runs — it still never executes it.

## Two dispatch modes

- **Pull** — `tide.work()` claims ready runs with `SKIP LOCKED` semantics: two workers can never receive the same job, and per-queue concurrency caps and rate limits hold under contention. Available in TypeScript and Python (incl. asyncio).
- **Push** — configure a queue with an `invokeUrl` and Tidebase delivers signed `run.invoke` webhooks to your app (same HMAC as recovery webhooks). At-least-once with a redelivery horizon; beginning the run by id makes redelivery safe.

## Cron schedules

```typescript
await tide.schedules.set('daily-digest', { cron: '0 9 * * *', workflowName: 'daily-digest' })
```

Five-field UTC cron. Each fire enqueues with a dedupe key derived from the fire time (`sched:<name>:<time>`), so a double-fire is structurally impossible — even with multiple server replicas.

## Retries, worker death, and the reconciler

A failed run with attempts remaining transitions back to `queued` with exponential backoff; exhausting `maxAttempts` records `failure_class: 'max_retries'`. If a worker dies mid-run, the lease expires and the reconciler — one advisory-locked loop — requeues the run (completed steps replay from checkpoints, so the retry never re-pays for finished work). Stalled non-queue runs get their signed recovery webhook fired automatically.

## Cancellation

```typescript
await tide.runs.cancel(runId, { reason: 'customer asked', actor: 'support' })
```

Cancellation is authoritative, durable, and one-way: status flips to `cancelled` immediately, in-flight workers observe it at their next step or gate boundary (`RunCancelledError` in TS, `RunCancelled` in Python — including a worker blocked waiting on a gate), and a `complete` or `fail` arriving afterwards is refused. Deadlines (`deadlineMs`) cancel automatically with reason `deadline`. It is impossible to miss because user code skipped a cleanup branch — the server enforces it, not your `finally` block.

## The lifecycle, in one place

`pending/queued → running → completed | failed | cancelled`, with `failure_class` on terminal failures. Don't mirror it into your own status columns — query `GET /runs/:id` or subscribe to events. Every guarantee on this page is enforced by an invariant test with concurrency probes against real Postgres.

See also: [The replay contract](replay-contract-is-it-safe-to-rerun.md) · [How to resume a failed run](how-to-resume-a-failed-ai-agent-run.md)