Documentation menu

Task lifecycle

Durable task lifecycle

How long-running work survives a restart: the six states, the allowed transitions, idempotent submission, retry backoff, refusal codes and the 64 KiB bounds.

Verified against the checkout built for v2.2.4 · updated 20 Oct 2026

On this page

A session backup, a dead-letter replay, a whole-installation export: each of these outlives the HTTP request that starts it. Rather than holding a connection open, the API writes a row and returns it. The row is the contract — poll it, cancel it, retry it — and it is owned by the same tenant rule as everything else, which is why a foreign task id answers exactly like one that does not exist.

Why a task queue instead of a long request

  • ·Restarts do not lose work. The row is in the database, so a container replaced mid-backup comes back to a claimable PENDING job rather than an orphaned promise.
  • ·Progress is observable. GET /api/tasks shows the state, the attempt count and the last error, so "is it still running?" is a query, not a guess.
  • ·Failures are recoverable. A transient Telegram or network failure reaches RETRY_WAIT with backoff; a spent budget reaches FAILED, where an operator can retry it deliberately.
  • ·Retries are safe. The idempotency rule means a client that retries a submission gets the original job back, not a second copy of the work.

States and transitions

  1. PENDING

    Accepted and stored. Any worker may claim it; nothing has run yet.

    cancellable

  2. RUNNING

    A worker holds the claim and is executing. The attempt counter has already been incremented.

    running_not_cancellable

  3. RETRY_WAIT

    The attempt failed in a way that is worth retrying. The backoff delay is counted down, and the job stays cancellable while it waits.

    cancellable

  4. SUCCEEDED

    The work finished and its result is stored with the row. Terminal.

    not_cancellable

  5. FAILED

    The attempt budget is spent, or the failure was not worth retrying. Terminal — unless the owner explicitly retries it.

    retryable

  6. CANCELLED

    The owner stopped it before it ran, or while it waited for a retry. Terminal.

    not_cancellable

The complete transition table — anything not listed here is refused
FromToTrigger
PENDINGRUNNING, CANCELLEDA worker claims it, or the owner stops it before the claim.
RUNNINGSUCCEEDED, FAILED, CANCELLED, RETRY_WAITThe one state with four exits: done, permanent failure, a cancelled-and-cooperative run, or another attempt later.
RETRY_WAITPENDING, RUNNING, CANCELLEDThe delay expires and the job is queued again, or the owner stops it while it waits.
FAILEDPENDINGOnly an explicit retry re-queues it, and only from FAILED.
SUCCEEDED—Terminal. Nothing transitions out.
CANCELLED—Terminal. Nothing transitions out.

Task kinds

The kinds a caller may submit
KindStarted byWhat it produces
session.backupPOST /api/sessions/{id}/backupAn encrypted archive of the session file, written only after the export succeeds.
dlq.replayDead-letter replay from the dashboard or POST /api/tasksThe refused deliveries re-attempted against their original targets.
dlq.purgeDELETE /api/dlqThe caller's dead-letter entries removed, ownership applied before the delete.
backup.exportPOST /api/backup/exportA configuration bundle that can be imported on another host.

Idempotent submission

The identity of a job is the trio of owner, kind and idempotency_key. The owner comes from the verified token — never from the body — so two tenants may use the same key without colliding, and a client that retried a submission after a timeout gets its original job back instead of duplicating the work.

First submission — 201 Created
{
  "task": {
    "id": "6f1c…",
    "kind": "dlq.replay",
    "status": "PENDING",
    "idempotency_key": "replay-2026-10-19",
    "attempt": 0,
    "max_attempts": 3
  },
  "already_existed": false,
  "id": "6f1c…",
  "status": "PENDING"
}
The same submission again — 200 OK, same job
{
  "task": { "id": "6f1c…", "status": "RUNNING", "attempt": 1 },
  "already_existed": true,
  "id": "6f1c…",
  "status": "RUNNING"
}

Attempts and backoff

Retry behaviour, in the order you will meet it
RuleValueWhy
Default attempt budget3 attemptsEnough to ride out a transient failure, short enough that a permanent one is reported the same day.
Maximum attempt budget8A requested budget is clamped server-side rather than rejected, so an exaggerated value cannot create an unbounded retry loop.
Backoff5s → 20s → 80s → …, factor 4, capped at 5 minGrowth fast enough to stop hammering a service that is down, capped so a long queue still drains.
Attempt accountingIncremented when the attempt startsA worker that dies mid-attempt does not get a free retry; the attempt it was running is visible in the row.
Explicit retryFAILED → PENDING with a fresh budgetAn operator who fixed the cause re-queues the job; anything not in FAILED answers not_retryable.
Terminal retention30 days by default, configurableFinished rows are purged by a scheduled job; nothing that is still PENDING, RUNNING or RETRY_WAIT is ever eligible.

Bounds and refusals

Every way a task call can be refused
ConditionAnswerDetail
Payload or result over the cap400 payload_too_largeEach is capped at 64 KiB and must be valid JSON. The message names which side exceeded the cap.
Unknown kind400 unknown_task_kindThe dispatcher works on a closed set of four kinds.
Cancelling a finished job400 not_cancellableA SUCCEEDED, FAILED or CANCELLED job can only be retried (if FAILED) or ignored.
Cancelling a running job400 running_not_cancellableThe row is left in RUNNING, because that is the truth.
Retrying anything not FAILED400 not_retryableOnly a failed job has an attempt budget left to spend.
Reading or cancelling a foreign job404 not_foundOwner and id are both in the query, so the answer is the same as for a job that never existed.
Listing without boundsGET /api/tasksPages with limit (1–200, default 50) and offset; an out-of-range value is clamped before it is narrowed to an int.

Walkthrough

Submit, poll, then recover a failed job

201 → 200 → 200 OK

The same key is used for both submissions, so the second call is answered from the store. Only after the job reaches FAILED does the explicit retry become legal.

Request

Submit, poll, then recover a failed job
# 1 — submit (retry-safe)
POST /api/tasks
{
  "kind": "dlq.replay",
  "payload": { "ids": ["a1","a2"] },
  "idempotency_key": "replay-2026-10-19",
  "max_attempts": 3
}

# 2 — poll one job
GET /api/tasks/6f1c…

# 3 — it exhausted its attempts
GET /api/tasks/6f1c…
{ "task": { "status": "FAILED", "attempt": 3, "max_attempts": 3 } }

# 4 — retry it, now that the cause is fixed
POST /api/tasks/6f1c…/retry

Response

Response body
{
  "task": {
    "id": "6f1c…",
    "owner_user_id": 5094837833,
    "kind": "dlq.replay",
    "status": "PENDING",
    "attempt": 0,
    "max_attempts": 3,
    "created_at": 1790000000
  },
  "already_existed": false,
  "id": "6f1c…",
  "status": "PENDING"
}

A cancel that is refused, honestly

400 Bad Request

A reminder that the API reports state rather than intent: the row is not silently marked CANCELLED, so a poll series never shows a value the worker contradicts.

Request

A cancel that is refused, honestly
POST /api/tasks/6f1c…/cancel

Response

Response body
{
  "error": "running_not_cancellable",
  "error_code": "running_not_cancellable"
}