Tidebase Tidebase GitHub Start self-hosting
Docs

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.

// 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

  • Pulltide.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

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

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 · How to resume a failed run